diff --git a/.coveragerc b/.coveragerc index dc35999768f..1f28a9a2aee 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,8 @@ omit = homeassistant/components/acmeda/helpers.py homeassistant/components/acmeda/hub.py homeassistant/components/acmeda/sensor.py + homeassistant/components/adax/__init__.py + homeassistant/components/adax/climate.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py @@ -35,7 +37,6 @@ omit = homeassistant/components/airnow/__init__.py homeassistant/components/airnow/sensor.py homeassistant/components/airvisual/__init__.py - homeassistant/components/airvisual/air_quality.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/* homeassistant/components/alarmdecoder/__init__.py @@ -105,6 +106,8 @@ omit = homeassistant/components/bloomsky/* homeassistant/components/bluesound/* homeassistant/components/bluetooth_tracker/* + homeassistant/components/bme280/__init__.py + homeassistant/components/bme280/const.py homeassistant/components/bme280/sensor.py homeassistant/components/bme680/sensor.py homeassistant/components/bmp280/sensor.py @@ -131,9 +134,7 @@ omit = homeassistant/components/brottsplatskartan/sensor.py homeassistant/components/browser/* homeassistant/components/brunt/cover.py - homeassistant/components/bsblan/__init__.py homeassistant/components/bsblan/climate.py - homeassistant/components/bsblan/const.py homeassistant/components/bt_home_hub_5/device_tracker.py homeassistant/components/bt_smarthub/device_tracker.py homeassistant/components/buienradar/sensor.py @@ -153,7 +154,6 @@ omit = homeassistant/components/clicksend/notify.py homeassistant/components/clicksend_tts/notify.py homeassistant/components/cmus/media_player.py - homeassistant/components/co2signal/* homeassistant/components/coinbase/sensor.py homeassistant/components/comed_hourly_pricing/sensor.py homeassistant/components/comfoconnect/fan.py @@ -275,6 +275,7 @@ omit = homeassistant/components/esphome/fan.py homeassistant/components/esphome/light.py homeassistant/components/esphome/number.py + homeassistant/components/esphome/select.py homeassistant/components/esphome/sensor.py homeassistant/components/esphome/switch.py homeassistant/components/essent/sensor.py @@ -320,7 +321,8 @@ omit = homeassistant/components/flick_electric/const.py homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py - homeassistant/components/flume/* + homeassistant/components/flume/__init__.py + homeassistant/components/flume/sensor.py homeassistant/components/flunearyou/__init__.py homeassistant/components/flunearyou/sensor.py homeassistant/components/flux_led/light.py @@ -348,7 +350,6 @@ omit = homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py - homeassistant/components/fritzbox_netmonitor/sensor.py homeassistant/components/fronius/sensor.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py @@ -356,12 +357,9 @@ omit = homeassistant/components/garages_amsterdam/__init__.py homeassistant/components/garages_amsterdam/binary_sensor.py homeassistant/components/garages_amsterdam/sensor.py - homeassistant/components/garmin_connect/__init__.py - homeassistant/components/garmin_connect/const.py - homeassistant/components/garmin_connect/sensor.py - homeassistant/components/garmin_connect/alarm_util.py homeassistant/components/gc100/* homeassistant/components/geniushub/* + homeassistant/components/generic_hygrostat/* homeassistant/components/github/sensor.py homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py @@ -372,6 +370,7 @@ omit = homeassistant/components/goalfeed/* homeassistant/components/goalzero/__init__.py homeassistant/components/goalzero/binary_sensor.py + homeassistant/components/goalzero/sensor.py homeassistant/components/goalzero/switch.py homeassistant/components/google/* homeassistant/components/google_cloud/tts.py @@ -398,10 +397,6 @@ omit = homeassistant/components/habitica/const.py homeassistant/components/habitica/sensor.py homeassistant/components/hangouts/* - homeassistant/components/hangouts/__init__.py - homeassistant/components/hangouts/const.py - homeassistant/components/hangouts/hangouts_bot.py - homeassistant/components/hangouts/hangups_utils.py homeassistant/components/harman_kardon_avr/media_player.py homeassistant/components/harmony/const.py homeassistant/components/harmony/data.py @@ -415,7 +410,8 @@ omit = homeassistant/components/heatmiser/climate.py homeassistant/components/hikvision/binary_sensor.py homeassistant/components/hikvisioncam/switch.py - homeassistant/components/hisense_aehw4a1/* + homeassistant/components/hisense_aehw4a1/__init__.py + homeassistant/components/hisense_aehw4a1/climate.py homeassistant/components/hitron_coda/device_tracker.py homeassistant/components/hive/__init__.py homeassistant/components/hive/climate.py @@ -426,15 +422,18 @@ omit = homeassistant/components/hive/water_heater.py homeassistant/components/hlk_sw16/__init__.py homeassistant/components/hlk_sw16/switch.py - homeassistant/components/home_connect/* + homeassistant/components/home_connect/__init__.py + homeassistant/components/home_connect/api.py + homeassistant/components/home_connect/binary_sensor.py + homeassistant/components/home_connect/entity.py + homeassistant/components/home_connect/light.py + homeassistant/components/home_connect/sensor.py + homeassistant/components/home_connect/switch.py homeassistant/components/homematic/* - homeassistant/components/homematic/climate.py - homeassistant/components/homematic/cover.py - homeassistant/components/homematic/notify.py homeassistant/components/home_plus_control/api.py - homeassistant/components/home_plus_control/helpers.py homeassistant/components/home_plus_control/switch.py homeassistant/components/homeworks/* + homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py homeassistant/components/horizon/media_player.py homeassistant/components/hp_ilo/sensor.py @@ -525,8 +524,6 @@ omit = homeassistant/components/kira/* homeassistant/components/kiwi/lock.py homeassistant/components/knx/* - homeassistant/components/knx/climate.py - homeassistant/components/knx/cover.py homeassistant/components/kodi/__init__.py homeassistant/components/kodi/browse_media.py homeassistant/components/kodi/const.py @@ -624,6 +621,7 @@ omit = homeassistant/components/mill/__init__.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py + homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/const.py @@ -638,6 +636,7 @@ omit = homeassistant/components/modbus/cover.py homeassistant/components/modbus/climate.py homeassistant/components/modbus/modbus.py + homeassistant/components/modbus/validators.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/const.py @@ -655,7 +654,6 @@ omit = homeassistant/components/mvglive/sensor.py homeassistant/components/mychevy/* homeassistant/components/mycroft/* - homeassistant/components/mycroft/notify.py homeassistant/components/mysensors/__init__.py homeassistant/components/mysensors/binary_sensor.py homeassistant/components/mysensors/climate.py @@ -692,6 +690,7 @@ omit = homeassistant/components/neurio_energy/sensor.py homeassistant/components/nexia/climate.py homeassistant/components/nextcloud/* + homeassistant/components/nfandroidtv/__init__.py homeassistant/components/nfandroidtv/notify.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py @@ -827,8 +826,6 @@ omit = homeassistant/components/radarr/sensor.py homeassistant/components/radiotherm/climate.py homeassistant/components/rainbird/* - homeassistant/components/rainbird/sensor.py - homeassistant/components/rainbird/switch.py homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py homeassistant/components/rainmachine/binary_sensor.py @@ -875,7 +872,6 @@ omit = homeassistant/components/rova/sensor.py homeassistant/components/rpi_camera/* homeassistant/components/rpi_gpio/* - homeassistant/components/rpi_gpio/cover.py homeassistant/components/rpi_gpio_pwm/light.py homeassistant/components/rpi_pfio/* homeassistant/components/rpi_rf/switch.py @@ -895,7 +891,6 @@ omit = homeassistant/components/screenlogic/services.py homeassistant/components/screenlogic/switch.py homeassistant/components/scsgate/* - homeassistant/components/scsgate/cover.py homeassistant/components/sendgrid/notify.py homeassistant/components/sense/* homeassistant/components/sensehat/light.py @@ -973,7 +968,6 @@ omit = homeassistant/components/sonos/* homeassistant/components/sony_projector/switch.py homeassistant/components/spc/* - homeassistant/components/speedtestdotnet/* homeassistant/components/spider/* homeassistant/components/splunk/* homeassistant/components/spotify/__init__.py @@ -998,8 +992,6 @@ omit = homeassistant/components/swiss_public_transport/sensor.py homeassistant/components/swisscom/device_tracker.py homeassistant/components/switchbot/switch.py - homeassistant/components/switcher_kis/sensor.py - homeassistant/components/switcher_kis/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py @@ -1020,7 +1012,6 @@ omit = homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* - homeassistant/components/tado/device_tracker.py homeassistant/components/tahoma/* homeassistant/components/tank_utility/sensor.py homeassistant/components/tankerkoenig/* @@ -1088,9 +1079,6 @@ omit = homeassistant/components/traccar/const.py homeassistant/components/trackr/device_tracker.py homeassistant/components/tradfri/* - homeassistant/components/tradfri/light.py - homeassistant/components/tradfri/cover.py - homeassistant/components/tradfri/base_class.py homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/sensor.py @@ -1211,20 +1199,29 @@ omit = homeassistant/components/xiaomi_miio/device_tracker.py homeassistant/components/xiaomi_miio/fan.py homeassistant/components/xiaomi_miio/gateway.py + homeassistant/components/xiaomi_miio/humidifier.py homeassistant/components/xiaomi_miio/light.py + homeassistant/components/xiaomi_miio/number.py homeassistant/components/xiaomi_miio/remote.py + homeassistant/components/xiaomi_miio/select.py homeassistant/components/xiaomi_miio/sensor.py homeassistant/components/xiaomi_miio/switch.py homeassistant/components/xiaomi_miio/vacuum.py homeassistant/components/xiaomi_tv/media_player.py homeassistant/components/xmpp/notify.py homeassistant/components/xs1/* + homeassistant/components/yale_smart_alarm/__init__.py homeassistant/components/yale_smart_alarm/alarm_control_panel.py + homeassistant/components/yale_smart_alarm/const.py + homeassistant/components/yale_smart_alarm/coordinator.py homeassistant/components/yamaha_musiccast/__init__.py homeassistant/components/yamaha_musiccast/media_player.py homeassistant/components/yandex_transport/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py + homeassistant/components/youless/__init__.py + homeassistant/components/youless/const.py + homeassistant/components/youless/sensor.py homeassistant/components/zabbix/* homeassistant/components/zamg/sensor.py homeassistant/components/zamg/weather.py diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0c30f6887b2..89a408c3b6a 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -115,7 +115,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.06.2 + uses: home-assistant/builder@2021.07.0 with: args: | $BUILD_ARGS \ @@ -134,6 +134,7 @@ jobs: machine: - generic-x86-64 - intel-nuc + - khadas-vim3 - odroid-c2 - odroid-c4 - odroid-n2 @@ -167,7 +168,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2021.06.2 + uses: home-assistant/builder@2021.07.0 with: args: | $BUILD_ARGS \ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0809cf604cf..06d22228a28 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -740,4 +740,4 @@ jobs: coverage report --fail-under=94 coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1.5.2 + uses: codecov/codecov-action@v2.0.2 diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 3059dc5e2ef..62c7299c2b8 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,7 +9,7 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2.0.3 + - uses: dessant/lock-threads@v2.1.1 with: github-token: ${{ github.token }} issue-lock-inactive-days: "30" diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d41deb9ec92..4770780341d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: # - No PRs marked as no-stale # - No issues marked as no-stale or help-wanted - name: 90 days stale issues & PRs policy - uses: actions/stale@v3.0.19 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 90 @@ -53,7 +53,7 @@ jobs: # - No PRs marked as no-stale or new-integrations # - No issues (-1) - name: 30 days stale PRs policy - uses: actions/stale@v3.0.19 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 @@ -78,7 +78,7 @@ jobs: # - No Issues marked as no-stale or help-wanted # - No PRs (-1) - name: Needs more information stale issues policy - uses: actions/stale@v3.0.19 + uses: actions/stale@v4 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-labels: "needs-more-information" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 8e97c61194b..cce57401ea8 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -81,7 +81,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2021.06.0 + uses: home-assistant/wheels@2021.07.0 with: tag: ${{ matrix.tag }} arch: ${{ matrix.arch }} @@ -150,7 +150,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2021.06.0 + uses: home-assistant/wheels@2021.07.0 with: tag: ${{ matrix.tag }} arch: ${{ matrix.arch }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31d5e9dd16c..1e36ae652d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.16.0 + rev: v2.23.0 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/psf/black - rev: 21.6b0 + rev: 21.7b0 hooks: - id: black args: @@ -70,7 +70,7 @@ repos: - id: prettier stages: [manual] - repo: https://github.com/cdce8p/python-typing-update - rev: v0.3.3 + rev: v0.3.5 hooks: # Run `python-typing-update` hook manually from time to time # to update python typing syntax. diff --git a/.strict-typing b/.strict-typing index 09578153163..6066c158b99 100644 --- a/.strict-typing +++ b/.strict-typing @@ -9,15 +9,18 @@ homeassistant.components.actiontec.* homeassistant.components.aftership.* homeassistant.components.air_quality.* homeassistant.components.airly.* +homeassistant.components.airvisual.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.amazon_polly.* homeassistant.components.ambee.* +homeassistant.components.ambient_station.* homeassistant.components.ampio.* homeassistant.components.automation.* homeassistant.components.binary_sensor.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bond.* +homeassistant.components.braviatv.* homeassistant.components.brother.* homeassistant.components.calendar.* homeassistant.components.camera.* @@ -25,17 +28,24 @@ homeassistant.components.canary.* homeassistant.components.cover.* homeassistant.components.device_automation.* homeassistant.components.device_tracker.* +homeassistant.components.devolo_home_control.* homeassistant.components.dnsip.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.elgato.* +homeassistant.components.esphome.* +homeassistant.components.energy.* +homeassistant.components.fastdotcom.* homeassistant.components.fitbit.* +homeassistant.components.flunearyou.* homeassistant.components.forecast_solar.* homeassistant.components.fritzbox.* homeassistant.components.frontend.* +homeassistant.components.fritz.* homeassistant.components.geo_location.* homeassistant.components.gios.* homeassistant.components.group.* +homeassistant.components.guardian.* homeassistant.components.history.* homeassistant.components.homeassistant.triggers.event homeassistant.components.http.* @@ -45,6 +55,7 @@ homeassistant.components.image_processing.* homeassistant.components.integration.* homeassistant.components.knx.* homeassistant.components.kraken.* +homeassistant.components.lcn.* homeassistant.components.light.* homeassistant.components.local_ip.* homeassistant.components.lock.* @@ -52,30 +63,43 @@ homeassistant.components.mailbox.* homeassistant.components.media_player.* homeassistant.components.mysensors.* homeassistant.components.nam.* +homeassistant.components.nest.* +homeassistant.components.netatmo.* homeassistant.components.network.* homeassistant.components.no_ip.* homeassistant.components.notify.* +homeassistant.components.notion.* homeassistant.components.number.* homeassistant.components.onewire.* +homeassistant.components.openuv.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.proximity.* +homeassistant.components.rainmachine.* +homeassistant.components.recollect_waste.* homeassistant.components.recorder.purge homeassistant.components.recorder.repack homeassistant.components.recorder.statistics homeassistant.components.remote.* +homeassistant.components.renault.* +homeassistant.components.rituals_perfume_genie.* homeassistant.components.scene.* homeassistant.components.select.* homeassistant.components.sensor.* +homeassistant.components.shelly.* +homeassistant.components.simplisafe.* homeassistant.components.slack.* homeassistant.components.sonos.media_player homeassistant.components.ssdp.* homeassistant.components.stream.* homeassistant.components.sun.* homeassistant.components.switch.* +homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* +homeassistant.components.tag.* homeassistant.components.tcp.* +homeassistant.components.tile.* homeassistant.components.tts.* homeassistant.components.upcloud.* homeassistant.components.uptime.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 0226b3f4361..24d643b96bc 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,13 +2,10 @@ "version": "2.0.0", "tasks": [ { - "label": "Preview", + "label": "Run Home Assistant Core", "type": "shell", "command": "hass -c ./config", - "group": { - "kind": "test", - "isDefault": true - }, + "group": "test", "presentation": { "reveal": "always", "panel": "new" @@ -19,7 +16,9 @@ "label": "Pytest", "type": "shell", "command": "pytest --timeout=10 tests", - "dependsOn": ["Install all Test Requirements"], + "dependsOn": [ + "Install all Test Requirements" + ], "group": { "kind": "test", "isDefault": true @@ -48,7 +47,9 @@ "label": "Pylint", "type": "shell", "command": "pylint homeassistant", - "dependsOn": ["Install all Requirements"], + "dependsOn": [ + "Install all Requirements" + ], "group": { "kind": "test", "isDefault": true diff --git a/CODEOWNERS b/CODEOWNERS index c651e35dcc3..29906631254 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -22,6 +22,7 @@ homeassistant/scripts/check_config.py @kellerza homeassistant/components/abode/* @shred86 homeassistant/components/accuweather/* @bieniu homeassistant/components/acmeda/* @atmurray +homeassistant/components/adax/* @danielhiversen homeassistant/components/adguard/* @frenck homeassistant/components/advantage_air/* @Bre77 homeassistant/components/aemet/* @noltari @@ -97,7 +98,7 @@ homeassistant/components/configurator/* @home-assistant/core homeassistant/components/control4/* @lawtancool homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund -homeassistant/components/coronavirus/* @home_assistant/core +homeassistant/components/coronavirus/* @home-assistant/core homeassistant/components/counter/* @fabaff homeassistant/components/cover/* @home-assistant/core homeassistant/components/cpuspeed/* @fabaff @@ -139,6 +140,7 @@ homeassistant/components/emby/* @mezz64 homeassistant/components/emoncms/* @borpin homeassistant/components/emonitor/* @bdraco homeassistant/components/emulated_kasa/* @kbickar +homeassistant/components/energy/* @home-assistant/core homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer homeassistant/components/enphase_envoy/* @gtdiehl @@ -160,6 +162,7 @@ homeassistant/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP homeassistant/components/fixer/* @fabaff homeassistant/components/flick_electric/* @ZephireNZ +homeassistant/components/flipr/* @cnico homeassistant/components/flo/* @dmulcahey homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco @@ -175,8 +178,8 @@ homeassistant/components/fritzbox/* @mib1185 homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garages_amsterdam/* @klaasnicolaas -homeassistant/components/garmin_connect/* @cyberjunky homeassistant/components/gdacs/* @exxamalte +homeassistant/components/generic_hygrostat/* @Shulyaka homeassistant/components/geniushub/* @zxdavb homeassistant/components/geo_json_events/* @exxamalte homeassistant/components/geo_rss_events/* @exxamalte @@ -213,6 +216,7 @@ homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit/* @bdraco homeassistant/components/homekit_controller/* @Jc2k @bdraco homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/honeywell/* @rdfurman homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop @fphammerle homeassistant/components/huawei_router/* @abmantis @@ -329,6 +333,7 @@ homeassistant/components/netdata/* @fabaff homeassistant/components/nexia/* @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nextcloud/* @meichthys +homeassistant/components/nfandroidtv/* @tkdrob homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole @@ -384,6 +389,7 @@ homeassistant/components/powerwall/* @bdraco @jrester homeassistant/components/profiler/* @bdraco homeassistant/components/progettihwsw/* @ardaseremet homeassistant/components/prometheus/* @knyar +homeassistant/components/prosegur/* @dgomes homeassistant/components/proxmoxve/* @k4ds3 @jhollowe @Corbeno homeassistant/components/ps4/* @ktnrg45 homeassistant/components/push/* @dgomes @@ -404,6 +410,7 @@ homeassistant/components/rainmachine/* @bachya homeassistant/components/random/* @fabaff homeassistant/components/recollect_waste/* @bachya homeassistant/components/rejseplanen/* @DarkFox +homeassistant/components/renault/* @epenet homeassistant/components/repetier/* @MTrab homeassistant/components/rflink/* @javicalle homeassistant/components/rfxtrx/* @danielhiversen @elupus @RobBie1221 @@ -442,6 +449,7 @@ homeassistant/components/sighthound/* @robmarkcole homeassistant/components/signal_messenger/* @bbernhard homeassistant/components/simplisafe/* @bachya homeassistant/components/sinch/* @bendikrb +homeassistant/components/siren/* @home-assistant/core @raman325 homeassistant/components/sisyphus/* @jkeljo homeassistant/components/sky_hub/* @rogerselwyn homeassistant/components/slack/* @bachya @@ -502,7 +510,7 @@ homeassistant/components/tapsaff/* @bazwilliams homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike -homeassistant/components/template/* @PhracturedBlue @tetienne +homeassistant/components/template/* @PhracturedBlue @tetienne @home-assistant/core homeassistant/components/tesla/* @zabuldon @alandtse homeassistant/components/tfiac/* @fredrike @mellado homeassistant/components/thethingsnetwork/* @fabaff @@ -554,11 +562,12 @@ homeassistant/components/wallbox/* @hesselonline homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff -homeassistant/components/webostv/* @bendavid +homeassistant/components/webostv/* @bendavid @thecode homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @esev homeassistant/components/wiffi/* @mampfes homeassistant/components/wilight/* @leofig-rj +homeassistant/components/wirelesstag/* @sergeymaysak homeassistant/components/withings/* @vangorra homeassistant/components/wled/* @frenck homeassistant/components/wolflink/* @adamkrol93 @@ -576,6 +585,7 @@ homeassistant/components/yandex_transport/* @rishatik92 @devbis homeassistant/components/yeelight/* @rytilahti @zewelor @shenxn homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yi/* @bachya +homeassistant/components/youless/* @gjong homeassistant/components/zeroconf/* @bdraco homeassistant/components/zerproc/* @emlove homeassistant/components/zha/* @dmulcahey @adminiuga diff --git a/build.json b/build.json index c3a3eec0bee..006d182c99d 100644 --- a/build.json +++ b/build.json @@ -2,11 +2,11 @@ "image": "homeassistant/{arch}-homeassistant", "shadow_repository": "ghcr.io/home-assistant", "build_from": { - "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.06.2", - "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.06.2", - "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.06.2", - "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.06.2", - "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.06.2" + "aarch64": "ghcr.io/home-assistant/aarch64-homeassistant-base:2021.07.0", + "armhf": "ghcr.io/home-assistant/armhf-homeassistant-base:2021.07.0", + "armv7": "ghcr.io/home-assistant/armv7-homeassistant-base:2021.07.0", + "amd64": "ghcr.io/home-assistant/amd64-homeassistant-base:2021.07.0", + "i386": "ghcr.io/home-assistant/i386-homeassistant-base:2021.07.0" }, "labels": { "io.hass.type": "core", diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index b01284d9974..177c3a10853 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -146,8 +146,8 @@ def daemonize() -> None: # redirect standard file descriptors to devnull # pylint: disable=consider-using-with - infd = open(os.devnull) - outfd = open(os.devnull, "a+") + infd = open(os.devnull, encoding="utf8") + outfd = open(os.devnull, "a+", encoding="utf8") sys.stdout.flush() sys.stderr.flush() os.dup2(infd.fileno(), sys.stdin.fileno()) @@ -159,7 +159,7 @@ def check_pid(pid_file: str) -> None: """Check that Home Assistant is not already running.""" # Check pid file try: - with open(pid_file) as file: + with open(pid_file, encoding="utf8") as file: pid = int(file.readline()) except OSError: # PID File does not exist @@ -182,7 +182,7 @@ def write_pid(pid_file: str) -> None: """Create a PID File.""" pid = os.getpid() try: - with open(pid_file, "w") as file: + with open(pid_file, "w", encoding="utf8") as file: file.write(str(pid)) except OSError: print(f"Fatal Error: Unable to write pid file {pid_file}") diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 65d553d4eb2..f462ad4be9d 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -1,7 +1,7 @@ """Auth provider that validates credentials via an external command.""" from __future__ import annotations -import asyncio.subprocess +import asyncio import collections from collections.abc import Mapping import logging @@ -64,7 +64,7 @@ class CommandLineAuthProvider(AuthProvider): """Validate a username and password.""" env = {"username": username, "password": password} try: - process = await asyncio.subprocess.create_subprocess_exec( # pylint: disable=no-member + process = await asyncio.create_subprocess_exec( self.config[CONF_COMMAND], *self.config[CONF_ARGS], env=env, diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 156dbae2804..be474661eac 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,5 +1,4 @@ """Support for the Abode Security System.""" -from copy import deepcopy from functools import partial from abodepy import Abode @@ -8,7 +7,6 @@ import abodepy.helpers.timeline as TIMELINE from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DATE, @@ -44,22 +42,7 @@ ATTR_APP_TYPE = "app_type" ATTR_EVENT_BY = "event_by" ATTR_VALUE = "value" -CONFIG_SCHEMA = vol.Schema( - vol.All( - # Deprecated in Home Assistant 2021.6 - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_POLLING, default=False): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.deprecated(DOMAIN) CHANGE_SETTING_SCHEMA = vol.Schema( {vol.Required(ATTR_SETTING): cv.string, vol.Required(ATTR_VALUE): cv.string} @@ -92,22 +75,6 @@ class AbodeSystem: self.logout_listener = None -async def async_setup(hass, config): - """Set up Abode integration.""" - if DOMAIN not in config: - return True - - conf = config[DOMAIN] - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=deepcopy(conf) - ) - ) - - return True - - async def async_setup_entry(hass, config_entry): """Set up Abode integration from a config entry.""" username = config_entry.data.get(CONF_USERNAME) @@ -284,17 +251,13 @@ class AbodeEntity(Entity): """Initialize Abode entity.""" self._data = data self._available = True + self._attr_should_poll = data.polling @property def available(self): """Return the available state.""" return self._available - @property - def should_poll(self): - """Return the polling state.""" - return self._data.polling - async def async_added_to_hass(self): """Subscribe to Abode connection status updates.""" await self.hass.async_add_executor_job( @@ -324,6 +287,8 @@ class AbodeDevice(AbodeEntity): """Initialize Abode device.""" super().__init__(data) self._device = device + self._attr_name = device.name + self._attr_unique_id = device.device_uuid async def async_added_to_hass(self): """Subscribe to device events.""" @@ -345,11 +310,6 @@ class AbodeDevice(AbodeEntity): """Update device state.""" self._device.refresh() - @property - def name(self): - """Return the name of the device.""" - return self._device.name - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -361,11 +321,6 @@ class AbodeDevice(AbodeEntity): "device_type": self._device.type, } - @property - def unique_id(self): - """Return a unique ID to use for this device.""" - return self._device.device_uuid - @property def device_info(self): """Return device registry information for this entity.""" @@ -388,22 +343,13 @@ class AbodeAutomation(AbodeEntity): """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 = { + ATTR_ATTRIBUTION: ATTRIBUTION, + "type": "CUE automation", + } def update(self): """Update automation state.""" self._automation.refresh() - - @property - def name(self): - """Return the name of the automation.""" - return self._automation.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, "type": "CUE automation"} - - @property - def unique_id(self): - """Return a unique ID to use for this automation.""" - return self._automation.automation_id diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 6d0c030e3e1..0cc1b500ff4 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -28,10 +28,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): """An alarm_control_panel implementation for Abode.""" - @property - def icon(self): - """Return the icon.""" - return ICON + _attr_icon = ICON + _attr_code_arm_required = False + _attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY @property def state(self): @@ -46,16 +45,6 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanelEntity): state = None return state - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return False - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - def alarm_disarm(self, code=None): """Send disarm command.""" self._device.set_standby() diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index bf51ffee81c..8b2f622d6e7 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -158,13 +158,3 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._password = user_input[CONF_PASSWORD] return await self._async_abode_login(step_id="reauth_confirm") - - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - if self._async_current_entries(): - LOGGER.warning("Already configured; Only a single configuration possible") - return self.async_abort(reason="single_instance_allowed") - - self._polling = import_config.get(CONF_POLLING, False) - - return await self.async_step_user(import_config) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index e3ececb62d9..f1f744a5511 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -41,23 +41,15 @@ class AbodeSensor(AbodeDevice, SensorEntity): """Initialize a sensor for an Abode device.""" super().__init__(data, device) self._sensor_type = sensor_type - self._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}" - self._device_class = SENSOR_TYPES[self._sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def unique_id(self): - """Return a unique ID to use for this device.""" - return f"{self._device.device_uuid}-{self._sensor_type}" + self._attr_name = f"{device.name} {SENSOR_TYPES[sensor_type][0]}" + self._attr_device_class = SENSOR_TYPES[self._sensor_type][1] + self._attr_unique_id = f"{device.device_uuid}-{sensor_type}" + if self._sensor_type == CONST.TEMP_STATUS_KEY: + self._attr_unit_of_measurement = device.temp_unit + elif self._sensor_type == CONST.HUMI_STATUS_KEY: + self._attr_unit_of_measurement = device.humidity_unit + elif self._sensor_type == CONST.LUX_STATUS_KEY: + self._attr_unit_of_measurement = device.lux_unit @property def state(self): @@ -68,13 +60,3 @@ class AbodeSensor(AbodeDevice, SensorEntity): return self._device.humidity if self._sensor_type == CONST.LUX_STATUS_KEY: return self._device.lux - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - if self._sensor_type == CONST.TEMP_STATUS_KEY: - return self._device.temp_unit - if self._sensor_type == CONST.HUMI_STATUS_KEY: - return self._device.humidity_unit - if self._sensor_type == CONST.LUX_STATUS_KEY: - return self._device.lux_unit diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 0985ce5ce2a..75c13962c43 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -48,6 +48,8 @@ class AbodeSwitch(AbodeDevice, SwitchEntity): class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): """A switch implementation for Abode automations.""" + _attr_icon = ICON + async def async_added_to_hass(self): """Set up trigger automation service.""" await super().async_added_to_hass() @@ -73,8 +75,3 @@ class AbodeAutomationSwitch(AbodeAutomation, SwitchEntity): def is_on(self): """Return True if the automation is enabled.""" return self._automation.is_enabled - - @property - def icon(self): - """Return the robot icon to match Home Assistant automations.""" - return ICON diff --git a/homeassistant/components/abode/translations/de.json b/homeassistant/components/abode/translations/de.json index 307f5f45065..695ecba621c 100644 --- a/homeassistant/components/abode/translations/de.json +++ b/homeassistant/components/abode/translations/de.json @@ -26,7 +26,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Gib deine Abode-Anmeldeinformationen ein" } diff --git a/homeassistant/components/abode/translations/es-419.json b/homeassistant/components/abode/translations/es-419.json index 9de6d9d185a..6d380e5bb43 100644 --- a/homeassistant/components/abode/translations/es-419.json +++ b/homeassistant/components/abode/translations/es-419.json @@ -1,9 +1,12 @@ { "config": { "abort": { + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de Abode." }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", "invalid_mfa_code": "C\u00f3digo MFA no v\u00e1lido" }, "step": { @@ -15,7 +18,8 @@ }, "reauth_confirm": { "data": { - "password": "Contrase\u00f1a" + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" }, "title": "Complete su informaci\u00f3n de inicio de sesi\u00f3n de Abode" }, diff --git a/homeassistant/components/abode/translations/hu.json b/homeassistant/components/abode/translations/hu.json index 260416b07bb..a4ce211d21a 100644 --- a/homeassistant/components/abode/translations/hu.json +++ b/homeassistant/components/abode/translations/hu.json @@ -20,7 +20,8 @@ "data": { "password": "Jelsz\u00f3", "username": "E-mail" - } + }, + "title": "T\u00f6ltse ki az Abode bejelentkez\u00e9si adatait" }, "user": { "data": { diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index aea394446ad..5802695afef 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -21,8 +21,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_PARTS_PER_CUBIC_METER, DEVICE_CLASS_TEMPERATURE, LENGTH_FEET, @@ -38,16 +36,12 @@ from homeassistant.const import ( UV_INDEX, ) -from .model import SensorDescription +from .model import AccuWeatherSensorDescription API_IMPERIAL: Final = "Imperial" API_METRIC: Final = "Metric" ATTRIBUTION: Final = "Data provided by AccuWeather" -ATTR_ENABLED: Final = "enabled" ATTR_FORECAST: Final = "forecast" -ATTR_LABEL: Final = "label" -ATTR_UNIT_IMPERIAL: Final = "unit_imperial" -ATTR_UNIT_METRIC: Final = "unit_metric" CONF_FORECAST: Final = "forecast" DOMAIN: Final = "accuweather" MANUFACTURER: Final = "AccuWeather, Inc." @@ -71,276 +65,263 @@ CONDITION_CLASSES: Final[dict[str, list[int]]] = { ATTR_CONDITION_WINDY: [32], } -FORECAST_SENSOR_TYPES: Final[dict[str, SensorDescription]] = { - "CloudCoverDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover Day", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - ATTR_ENABLED: False, - }, - "CloudCoverNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover Night", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - ATTR_ENABLED: False, - }, - "Grass": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:grass", - ATTR_LABEL: "Grass Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED: False, - }, - "HoursOfSun": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-partly-cloudy", - ATTR_LABEL: "Hours Of Sun", - ATTR_UNIT_METRIC: TIME_HOURS, - ATTR_UNIT_IMPERIAL: TIME_HOURS, - ATTR_ENABLED: True, - }, - "Mold": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_LABEL: "Mold Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED: False, - }, - "Ozone": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:vector-triangle", - ATTR_LABEL: "Ozone", - ATTR_UNIT_METRIC: None, - ATTR_UNIT_IMPERIAL: None, - ATTR_ENABLED: False, - }, - "Ragweed": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:sprout", - ATTR_LABEL: "Ragweed Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED: False, - }, - "RealFeelTemperatureMax": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Max", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: True, - }, - "RealFeelTemperatureMin": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Min", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: True, - }, - "RealFeelTemperatureShadeMax": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade Max", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - }, - "RealFeelTemperatureShadeMin": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade Min", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - }, - "ThunderstormProbabilityDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-lightning", - ATTR_LABEL: "Thunderstorm Probability Day", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - ATTR_ENABLED: True, - }, - "ThunderstormProbabilityNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-lightning", - ATTR_LABEL: "Thunderstorm Probability Night", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - ATTR_ENABLED: True, - }, - "Tree": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:tree-outline", - ATTR_LABEL: "Tree Pollen", - ATTR_UNIT_METRIC: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_UNIT_IMPERIAL: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED: False, - }, - "UVIndex": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-sunny", - ATTR_LABEL: "UV Index", - ATTR_UNIT_METRIC: UV_INDEX, - ATTR_UNIT_IMPERIAL: UV_INDEX, - ATTR_ENABLED: True, - }, - "WindGustDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust Day", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: False, - }, - "WindGustNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust Night", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: False, - }, - "WindDay": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Day", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: True, - }, - "WindNight": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Night", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: True, - }, -} +FORECAST_SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( + AccuWeatherSensorDescription( + key="CloudCoverDay", + icon="mdi:weather-cloudy", + name="Cloud Cover Day", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="CloudCoverNight", + icon="mdi:weather-cloudy", + name="Cloud Cover Night", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Grass", + icon="mdi:grass", + name="Grass Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="HoursOfSun", + icon="mdi:weather-partly-cloudy", + name="Hours Of Sun", + unit_metric=TIME_HOURS, + unit_imperial=TIME_HOURS, + ), + AccuWeatherSensorDescription( + key="Mold", + icon="mdi:blur", + name="Mold Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Ozone", + icon="mdi:vector-triangle", + name="Ozone", + unit_metric=None, + unit_imperial=None, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="Ragweed", + icon="mdi:sprout", + name="Ragweed Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureMax", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature Max", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureMin", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature Min", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMax", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature Shade Max", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShadeMin", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature Shade Min", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="ThunderstormProbabilityDay", + icon="mdi:weather-lightning", + name="Thunderstorm Probability Day", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + ), + AccuWeatherSensorDescription( + key="ThunderstormProbabilityNight", + icon="mdi:weather-lightning", + name="Thunderstorm Probability Night", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + ), + AccuWeatherSensorDescription( + key="Tree", + icon="mdi:tree-outline", + name="Tree Pollen", + unit_metric=CONCENTRATION_PARTS_PER_CUBIC_METER, + unit_imperial=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="UVIndex", + icon="mdi:weather-sunny", + name="UV Index", + unit_metric=UV_INDEX, + unit_imperial=UV_INDEX, + ), + AccuWeatherSensorDescription( + key="WindGustDay", + icon="mdi:weather-windy", + name="Wind Gust Day", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="WindGustNight", + icon="mdi:weather-windy", + name="Wind Gust Night", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + ), + AccuWeatherSensorDescription( + key="WindDay", + icon="mdi:weather-windy", + name="Wind Day", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + ), + AccuWeatherSensorDescription( + key="WindNight", + icon="mdi:weather-windy", + name="Wind Night", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + ), +) -SENSOR_TYPES: Final[dict[str, SensorDescription]] = { - "ApparentTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Apparent Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "Ceiling": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-fog", - ATTR_LABEL: "Cloud Ceiling", - ATTR_UNIT_METRIC: LENGTH_METERS, - ATTR_UNIT_IMPERIAL: LENGTH_FEET, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "CloudCover": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-cloudy", - ATTR_LABEL: "Cloud Cover", - ATTR_UNIT_METRIC: PERCENTAGE, - ATTR_UNIT_IMPERIAL: PERCENTAGE, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "DewPoint": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Dew Point", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "RealFeelTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "RealFeelTemperatureShade": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "RealFeel Temperature Shade", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "Precipitation": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-rainy", - ATTR_LABEL: "Precipitation", - ATTR_UNIT_METRIC: LENGTH_MILLIMETERS, - ATTR_UNIT_IMPERIAL: LENGTH_INCHES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "PressureTendency": { - ATTR_DEVICE_CLASS: "accuweather__pressure_tendency", - ATTR_ICON: "mdi:gauge", - ATTR_LABEL: "Pressure Tendency", - ATTR_UNIT_METRIC: None, - ATTR_UNIT_IMPERIAL: None, - ATTR_ENABLED: True, - }, - "UVIndex": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-sunny", - ATTR_LABEL: "UV Index", - ATTR_UNIT_METRIC: UV_INDEX, - ATTR_UNIT_IMPERIAL: UV_INDEX, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "WetBulbTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wet Bulb Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "WindChillTemperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wind Chill Temperature", - ATTR_UNIT_METRIC: TEMP_CELSIUS, - ATTR_UNIT_IMPERIAL: TEMP_FAHRENHEIT, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "Wind": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "WindGust": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust", - ATTR_UNIT_METRIC: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_IMPERIAL: SPEED_MILES_PER_HOUR, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, -} +SENSOR_TYPES: Final[tuple[AccuWeatherSensorDescription, ...]] = ( + AccuWeatherSensorDescription( + key="ApparentTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Apparent Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Ceiling", + icon="mdi:weather-fog", + name="Cloud Ceiling", + unit_metric=LENGTH_METERS, + unit_imperial=LENGTH_FEET, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="CloudCover", + icon="mdi:weather-cloudy", + name="Cloud Cover", + unit_metric=PERCENTAGE, + unit_imperial=PERCENTAGE, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="DewPoint", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Dew Point", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="RealFeelTemperatureShade", + device_class=DEVICE_CLASS_TEMPERATURE, + name="RealFeel Temperature Shade", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Precipitation", + icon="mdi:weather-rainy", + name="Precipitation", + unit_metric=LENGTH_MILLIMETERS, + unit_imperial=LENGTH_INCHES, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="PressureTendency", + device_class="accuweather__pressure_tendency", + icon="mdi:gauge", + name="Pressure Tendency", + unit_metric=None, + unit_imperial=None, + ), + AccuWeatherSensorDescription( + key="UVIndex", + icon="mdi:weather-sunny", + name="UV Index", + unit_metric=UV_INDEX, + unit_imperial=UV_INDEX, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WetBulbTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Wet Bulb Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WindChillTemperature", + device_class=DEVICE_CLASS_TEMPERATURE, + name="Wind Chill Temperature", + unit_metric=TEMP_CELSIUS, + unit_imperial=TEMP_FAHRENHEIT, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="Wind", + icon="mdi:weather-windy", + name="Wind", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + AccuWeatherSensorDescription( + key="WindGust", + icon="mdi:weather-windy", + name="Wind Gust", + unit_metric=SPEED_KILOMETERS_PER_HOUR, + unit_imperial=SPEED_MILES_PER_HOUR, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/accuweather/model.py b/homeassistant/components/accuweather/model.py index 2127629728b..e74a6d46057 100644 --- a/homeassistant/components/accuweather/model.py +++ b/homeassistant/components/accuweather/model.py @@ -1,16 +1,14 @@ """Type definitions for AccuWeather integration.""" from __future__ import annotations -from typing import TypedDict +from dataclasses import dataclass + +from homeassistant.components.sensor import SensorEntityDescription -class SensorDescription(TypedDict, total=False): - """Sensor description class.""" +@dataclass +class AccuWeatherSensorDescription(SensorEntityDescription): + """Class describing AccuWeather sensor entities.""" - device_class: str | None - icon: str | None - label: str - unit_metric: str | None - unit_imperial: str | None - enabled: bool - state_class: str | None + unit_metric: str | None = None + unit_imperial: str | None = None diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index ba99df14d9e..4a5af6054e1 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -3,17 +3,10 @@ from __future__ import annotations from typing import Any, cast -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, - CONF_NAME, - DEVICE_CLASS_TEMPERATURE, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, DEVICE_CLASS_TEMPERATURE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -22,11 +15,7 @@ from . import AccuWeatherDataUpdateCoordinator from .const import ( API_IMPERIAL, API_METRIC, - ATTR_ENABLED, ATTR_FORECAST, - ATTR_LABEL, - ATTR_UNIT_IMPERIAL, - ATTR_UNIT_METRIC, ATTRIBUTION, DOMAIN, FORECAST_SENSOR_TYPES, @@ -35,6 +24,7 @@ from .const import ( NAME, SENSOR_TYPES, ) +from .model import AccuWeatherSensorDescription PARALLEL_UPDATES = 1 @@ -48,17 +38,19 @@ async def async_setup_entry( coordinator: AccuWeatherDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] sensors: list[AccuWeatherSensor] = [] - for sensor in SENSOR_TYPES: - sensors.append(AccuWeatherSensor(name, sensor, coordinator)) + for description in SENSOR_TYPES: + sensors.append(AccuWeatherSensor(name, coordinator, description)) if coordinator.forecast: - for sensor in FORECAST_SENSOR_TYPES: + for description in FORECAST_SENSOR_TYPES: for day in range(MAX_FORECAST_DAYS + 1): # Some air quality/allergy sensors are only available for certain # locations. - if sensor in coordinator.data[ATTR_FORECAST][0]: + if description.key in coordinator.data[ATTR_FORECAST][0]: sensors.append( - AccuWeatherSensor(name, sensor, coordinator, forecast_day=day) + AccuWeatherSensor( + name, coordinator, description, forecast_day=day + ) ) async_add_entities(sensors) @@ -68,119 +60,107 @@ class AccuWeatherSensor(CoordinatorEntity, SensorEntity): """Define an AccuWeather entity.""" coordinator: AccuWeatherDataUpdateCoordinator + entity_description: AccuWeatherSensorDescription def __init__( self, name: str, - kind: str, coordinator: AccuWeatherDataUpdateCoordinator, + description: AccuWeatherSensorDescription, forecast_day: int | None = None, ) -> None: """Initialize.""" super().__init__(coordinator) - self._sensor_data = _get_sensor_data(coordinator.data, forecast_day, kind) - if forecast_day is None: - self._description = SENSOR_TYPES[kind] - else: - self._description = FORECAST_SENSOR_TYPES[kind] - self._unit_system = API_METRIC if coordinator.is_metric else API_IMPERIAL - self._name = name - self.kind = kind - self._device_class = None + self.entity_description = description + self._sensor_data = _get_sensor_data( + coordinator.data, forecast_day, description.key + ) self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - self.forecast_day = forecast_day - self._attr_state_class = self._description.get(ATTR_STATE_CLASS) - - @property - def name(self) -> str: - """Return the name.""" - if self.forecast_day is not None: - return f"{self._name} {self._description[ATTR_LABEL]} {self.forecast_day}d" - return f"{self._name} {self._description[ATTR_LABEL]}" - - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - if self.forecast_day is not None: - return f"{self.coordinator.location_key}-{self.kind}-{self.forecast_day}".lower() - return f"{self.coordinator.location_key}-{self.kind}".lower() - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.coordinator.location_key)}, + if forecast_day is not None: + self._attr_name = f"{name} {description.name} {forecast_day}d" + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}-{forecast_day}".lower() + ) + else: + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = ( + f"{coordinator.location_key}-{description.key}".lower() + ) + if coordinator.is_metric: + self._unit_system = API_METRIC + self._attr_unit_of_measurement = description.unit_metric + else: + self._unit_system = API_IMPERIAL + self._attr_unit_of_measurement = description.unit_imperial + self._attr_device_info = { + "identifiers": {(DOMAIN, coordinator.location_key)}, "name": NAME, "manufacturer": MANUFACTURER, "entry_type": "service", } + self.forecast_day = forecast_day @property def state(self) -> StateType: """Return the state.""" if self.forecast_day is not None: - if self._description["device_class"] == DEVICE_CLASS_TEMPERATURE: + if self.entity_description.device_class == DEVICE_CLASS_TEMPERATURE: return cast(float, self._sensor_data["Value"]) - if self.kind == "UVIndex": + if self.entity_description.key == "UVIndex": return cast(int, self._sensor_data["Value"]) - if self.kind in ["Grass", "Mold", "Ragweed", "Tree", "Ozone"]: + if self.entity_description.key in ("Grass", "Mold", "Ragweed", "Tree", "Ozone"): return cast(int, self._sensor_data["Value"]) - if self.kind == "Ceiling": + if self.entity_description.key == "Ceiling": return round(self._sensor_data[self._unit_system]["Value"]) - if self.kind == "PressureTendency": + if self.entity_description.key == "PressureTendency": return cast(str, self._sensor_data["LocalizedText"].lower()) - if self._description["device_class"] == DEVICE_CLASS_TEMPERATURE: + if self.entity_description.device_class == DEVICE_CLASS_TEMPERATURE: return cast(float, self._sensor_data[self._unit_system]["Value"]) - if self.kind == "Precipitation": + if self.entity_description.key == "Precipitation": return cast(float, self._sensor_data[self._unit_system]["Value"]) - if self.kind in ["Wind", "WindGust"]: + if self.entity_description.key in ("Wind", "WindGust"): return cast(float, self._sensor_data["Speed"][self._unit_system]["Value"]) - if self.kind in ["WindDay", "WindNight", "WindGustDay", "WindGustNight"]: + if self.entity_description.key in ( + "WindDay", + "WindNight", + "WindGustDay", + "WindGustNight", + ): return cast(StateType, self._sensor_data["Speed"]["Value"]) return cast(StateType, self._sensor_data) - @property - def icon(self) -> str | None: - """Return the icon.""" - return self._description[ATTR_ICON] - - @property - def device_class(self) -> str | None: - """Return the device_class.""" - return self._description[ATTR_DEVICE_CLASS] - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - if self.coordinator.is_metric: - return self._description[ATTR_UNIT_METRIC] - return self._description[ATTR_UNIT_IMPERIAL] - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.forecast_day is not None: - if self.kind in ["WindDay", "WindNight", "WindGustDay", "WindGustNight"]: + if self.entity_description.key in ( + "WindDay", + "WindNight", + "WindGustDay", + "WindGustNight", + ): self._attrs["direction"] = self._sensor_data["Direction"]["English"] - elif self.kind in ["Grass", "Mold", "Ragweed", "Tree", "UVIndex", "Ozone"]: + elif self.entity_description.key in ( + "Grass", + "Mold", + "Ozone", + "Ragweed", + "Tree", + "UVIndex", + ): self._attrs["level"] = self._sensor_data["Category"] return self._attrs - if self.kind == "UVIndex": + if self.entity_description.key == "UVIndex": self._attrs["level"] = self.coordinator.data["UVIndexText"] - elif self.kind == "Precipitation": + elif self.entity_description.key == "Precipitation": self._attrs["type"] = self.coordinator.data["PrecipitationType"] return self._attrs - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._description[ATTR_ENABLED] - @callback def _handle_coordinator_update(self) -> None: """Handle data update.""" self._sensor_data = _get_sensor_data( - self.coordinator.data, self.forecast_day, self.kind + self.coordinator.data, self.forecast_day, self.entity_description.key ) self.async_write_ha_state() diff --git a/homeassistant/components/accuweather/translations/ar.json b/homeassistant/components/accuweather/translations/ar.json new file mode 100644 index 00000000000..0694e096019 --- /dev/null +++ b/homeassistant/components/accuweather/translations/ar.json @@ -0,0 +1,30 @@ +{ + "config": { + "error": { + "requests_exceeded": "\u062a\u0645 \u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u0639\u062f\u062f \u0627\u0644\u0645\u0633\u0645\u0648\u062d \u0628\u0647 \u0645\u0646 \u0627\u0644\u0637\u0644\u0628\u0627\u062a \u0625\u0644\u0649 Accuweather API. \u0639\u0644\u064a\u0643 \u0627\u0644\u0627\u0646\u062a\u0638\u0627\u0631 \u0623\u0648 \u062a\u063a\u064a\u064a\u0631 \u0645\u0641\u062a\u0627\u062d API." + }, + "step": { + "user": { + "description": "\u0625\u0630\u0627 \u0643\u0646\u062a \u0628\u062d\u0627\u062c\u0629 \u0625\u0644\u0649 \u0645\u0633\u0627\u0639\u062f\u0629 \u0641\u064a \u0627\u0644\u062a\u0643\u0648\u064a\u0646 \u060c \u0641\u0642\u0645 \u0628\u0625\u0644\u0642\u0627\u0621 \u0646\u0638\u0631\u0629 \u0647\u0646\u0627: https://www.home-assistant.io/integrations/accuweather/ \n\n \u0644\u0627 \u064a\u062a\u0645 \u062a\u0645\u0643\u064a\u0646 \u0628\u0639\u0636 \u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0628\u0634\u0643\u0644 \u0627\u0641\u062a\u0631\u0627\u0636\u064a. \u064a\u0645\u0643\u0646\u0643 \u062a\u0645\u0643\u064a\u0646\u0647\u0645 \u0641\u064a \u0633\u062c\u0644 \u0627\u0644\u0643\u064a\u0627\u0646 \u0628\u0639\u062f \u062a\u0643\u0648\u064a\u0646 \u0627\u0644\u062a\u0643\u0627\u0645\u0644.\n \u0644\u0627 \u064a\u062a\u0645 \u062a\u0645\u0643\u064a\u0646 \u062a\u0648\u0642\u0639\u0627\u062a \u0627\u0644\u0637\u0642\u0633 \u0627\u0641\u062a\u0631\u0627\u0636\u064a\u064b\u0627. \u064a\u0645\u0643\u0646\u0643 \u062a\u0645\u0643\u064a\u0646\u0647 \u0641\u064a \u062e\u064a\u0627\u0631\u0627\u062a \u0627\u0644\u062a\u0643\u0627\u0645\u0644.", + "title": "AccuWeather" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "forecast": "\u0627\u0644\u0646\u0634\u0631\u0629 \u0627\u0644\u062c\u0648\u064a\u0629" + }, + "description": "\u0646\u0638\u0631\u064b\u0627 \u0644\u0642\u064a\u0648\u062f \u0627\u0644\u0625\u0635\u062f\u0627\u0631 \u0627\u0644\u0645\u062c\u0627\u0646\u064a \u0645\u0646 \u0645\u0641\u062a\u0627\u062d AccuWeather API \u060c \u0639\u0646\u062f \u062a\u0645\u0643\u064a\u0646 \u0627\u0644\u062a\u0646\u0628\u0624 \u0628\u0627\u0644\u0637\u0642\u0633 \u060c \u0633\u064a\u062a\u0645 \u0625\u062c\u0631\u0627\u0621 \u062a\u062d\u062f\u064a\u062b\u0627\u062a \u0627\u0644\u0628\u064a\u0627\u0646\u0627\u062a \u0643\u0644 80 \u062f\u0642\u064a\u0642\u0629 \u0628\u062f\u0644\u0627\u064b \u0645\u0646 \u0643\u0644 40 \u062f\u0642\u064a\u0642\u0629.", + "title": "\u062e\u064a\u0627\u0631\u0627\u062a AccuWeather" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "\u0627\u0644\u0648\u0635\u0648\u0644 \u0625\u0644\u0649 \u062e\u0627\u062f\u0645 AccuWeather", + "remaining_requests": "\u0627\u0644\u0637\u0644\u0628\u0627\u062a \u0627\u0644\u0645\u062a\u0628\u0642\u064a\u0629 \u0627\u0644\u0645\u0633\u0645\u0648\u062d \u0628\u0647\u0627" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/de.json b/homeassistant/components/accuweather/translations/de.json index a9b23bacf6c..17eb0ee31fc 100644 --- a/homeassistant/components/accuweather/translations/de.json +++ b/homeassistant/components/accuweather/translations/de.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", - "requests_exceeded": "Die zul\u00e4ssige Anzahl von Anforderungen an die Accuweather-API wurde \u00fcberschritten. Sie m\u00fcssen warten oder den API-Schl\u00fcssel \u00e4ndern." + "requests_exceeded": "Die zul\u00e4ssige Anzahl von Anforderungen an die Accuweather-API wurde \u00fcberschritten. Du musst warten oder den API-Schl\u00fcssel \u00e4ndern." }, "step": { "user": { diff --git a/homeassistant/components/accuweather/translations/es-419.json b/homeassistant/components/accuweather/translations/es-419.json index 92d5d5ef2c2..72d295da073 100644 --- a/homeassistant/components/accuweather/translations/es-419.json +++ b/homeassistant/components/accuweather/translations/es-419.json @@ -1,6 +1,11 @@ { "config": { + "abort": { + "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." + }, "error": { + "cannot_connect": "No se pudo conectar", + "invalid_api_key": "Clave de API no v\u00e1lida", "requests_exceeded": "Se super\u00f3 el n\u00famero permitido de solicitudes a la API de Accuweather. Tiene que esperar o cambiar la clave de API." }, "step": { diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 869e00ca064..219ce00872f 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -14,8 +14,22 @@ "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", "name": "\u05e9\u05dd" - } + }, + "title": "AccuWeather" } } + }, + "options": { + "step": { + "user": { + "description": "\u05d1\u05e9\u05dc \u05de\u05d2\u05d1\u05dc\u05d5\u05ea \u05d4\u05d2\u05d9\u05e8\u05e1\u05d4 \u05d4\u05d7\u05d9\u05e0\u05de\u05d9\u05ea \u05e9\u05dc \u05de\u05e4\u05ea\u05d7 \u05d4-API \u05e9\u05dc AccuWeather, \u05db\u05d0\u05e9\u05e8 \u05ea\u05e4\u05e2\u05d9\u05dc \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8, \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05e0\u05ea\u05d5\u05e0\u05d9\u05dd \u05d9\u05d1\u05d5\u05e6\u05e2\u05d5 \u05db\u05dc 80 \u05d3\u05e7\u05d5\u05ea \u05d1\u05de\u05e7\u05d5\u05dd \u05db\u05dc 40 \u05d3\u05e7\u05d5\u05ea.", + "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea AccuWeather" + } + } + }, + "system_health": { + "info": { + "can_reach_server": "\u05d4\u05e9\u05d2\u05ea \u05e9\u05e8\u05ea AccuWeather" + } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/hu.json b/homeassistant/components/accuweather/translations/hu.json index 8a0f7f5a198..ce4721693f3 100644 --- a/homeassistant/components/accuweather/translations/hu.json +++ b/homeassistant/components/accuweather/translations/hu.json @@ -25,5 +25,11 @@ "title": "AccuWeather be\u00e1ll\u00edt\u00e1sok" } } + }, + "system_health": { + "info": { + "can_reach_server": "\u00c9rje el az AccuWeather szervert", + "remaining_requests": "Fennmarad\u00f3 enged\u00e9lyezett k\u00e9r\u00e9sek" + } } } \ No newline at end of file diff --git a/homeassistant/components/accuweather/translations/sensor.ar.json b/homeassistant/components/accuweather/translations/sensor.ar.json new file mode 100644 index 00000000000..948bd1c95a2 --- /dev/null +++ b/homeassistant/components/accuweather/translations/sensor.ar.json @@ -0,0 +1,9 @@ +{ + "state": { + "accuweather__pressure_tendency": { + "falling": "\u0647\u0628\u0648\u0637", + "rising": "\u0627\u0631\u062a\u0641\u0627\u0639", + "steady": "\u062b\u0627\u0628\u062a" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 9a2ba769a82..cd8d64cc80f 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -19,7 +19,6 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp @@ -60,29 +59,15 @@ class AccuWeatherEntity(CoordinatorEntity, WeatherEntity): ) -> None: """Initialize.""" super().__init__(coordinator) - self._name = name - self._unit_system = API_METRIC if self.coordinator.is_metric else API_IMPERIAL - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return self.coordinator.location_key - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.coordinator.location_key)}, + self._unit_system = API_METRIC if coordinator.is_metric else API_IMPERIAL + self._attr_name = name + self._attr_unique_id = coordinator.location_key + self._attr_temperature_unit = ( + TEMP_CELSIUS if coordinator.is_metric else TEMP_FAHRENHEIT + ) + self._attr_attribution = ATTRIBUTION + self._attr_device_info = { + "identifiers": {(DOMAIN, coordinator.location_key)}, "name": NAME, "manufacturer": MANUFACTURER, "entry_type": "service", @@ -107,11 +92,6 @@ class AccuWeatherEntity(CoordinatorEntity, WeatherEntity): float, self.coordinator.data["Temperature"][self._unit_system]["Value"] ) - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS if self.coordinator.is_metric else TEMP_FAHRENHEIT - @property def pressure(self) -> float: """Return the pressure.""" diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 69aba415589..947b774b7bd 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -67,6 +67,8 @@ def setup_platform( class AcerSwitch(SwitchEntity): """Represents an Acer Projector as a switch.""" + _attr_icon = ICON + def __init__( self, serial_port: str, @@ -79,9 +81,7 @@ class AcerSwitch(SwitchEntity): port=serial_port, timeout=timeout, write_timeout=write_timeout ) self._serial_port = serial_port - self._name = name - self._state = False - self._available = False + self._attr_name = name self._attributes = { LAMP_HOURS: STATE_UNKNOWN, INPUT_SOURCE: STATE_UNKNOWN, @@ -116,57 +116,33 @@ class AcerSwitch(SwitchEntity): return match.group(1) return STATE_UNKNOWN - @property - def available(self) -> bool: - """Return if projector is available.""" - return self._available - - @property - def name(self) -> str: - """Return name of the projector.""" - return self._name - - @property - def icon(self) -> str: - """Return the icon.""" - return ICON - - @property - def is_on(self) -> bool: - """Return if the projector is turned on.""" - return self._state - - @property - def extra_state_attributes(self) -> dict[str, str]: - """Return state attributes.""" - return self._attributes - def update(self) -> None: """Get the latest state from the projector.""" awns = self._write_read_format(CMD_DICT[LAMP]) if awns == "Lamp 1": - self._state = True - self._available = True + self._attr_is_on = True + self._attr_available = True elif awns == "Lamp 0": - self._state = False - self._available = True + self._attr_is_on = False + self._attr_available = True else: - self._available = False + self._attr_available = False for key in self._attributes: msg = CMD_DICT.get(key) if msg: awns = self._write_read_format(msg) self._attributes[key] = awns + self._attr_extra_state_attributes = self._attributes def turn_on(self, **kwargs: Any) -> None: """Turn the projector on.""" msg = CMD_DICT[STATE_ON] self._write_read(msg) - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs: Any) -> None: """Turn the projector off.""" msg = CMD_DICT[STATE_OFF] self._write_read(msg) - self._state = False + self._attr_is_on = False diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py new file mode 100644 index 00000000000..0a14648af26 --- /dev/null +++ b/homeassistant/components/adax/__init__.py @@ -0,0 +1,18 @@ +"""The Adax integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Adax from a config entry.""" + hass.config_entries.async_setup_platforms(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/adax/climate.py b/homeassistant/components/adax/climate.py new file mode 100644 index 00000000000..74e973ba6d5 --- /dev/null +++ b/homeassistant/components/adax/climate.py @@ -0,0 +1,152 @@ +"""Support for Adax wifi-enabled home heaters.""" +from __future__ import annotations + +import logging +from typing import Any + +from adax import Adax + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_PASSWORD, + PRECISION_WHOLE, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ACCOUNT_ID + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Adax thermostat with config flow.""" + adax_data_handler = Adax( + entry.data[ACCOUNT_ID], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + + async_add_entities( + AdaxDevice(room, adax_data_handler) + for room in await adax_data_handler.get_rooms() + ) + + +class AdaxDevice(ClimateEntity): + """Representation of a heater.""" + + def __init__(self, heater_data: dict[str, Any], adax_data_handler: Adax) -> None: + """Initialize the heater.""" + self._heater_data = heater_data + self._adax_data_handler = adax_data_handler + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._heater_data['homeId']}_{self._heater_data['id']}" + + @property + def name(self) -> str: + """Return the name of the device, if any.""" + return self._heater_data["name"] + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if self._heater_data["heatingEnabled"]: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + @property + def icon(self) -> str: + """Return nice icon for heater.""" + if self.hvac_mode == HVAC_MODE_HEAT: + return "mdi:radiator" + return "mdi:radiator-off" + + @property + def hvac_modes(self) -> list[str]: + """Return the list of available hvac operation modes.""" + return [HVAC_MODE_HEAT, HVAC_MODE_OFF] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set hvac mode.""" + if hvac_mode == HVAC_MODE_HEAT: + temperature = max( + self.min_temp, self._heater_data.get("targetTemperature", self.min_temp) + ) + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + elif hvac_mode == HVAC_MODE_OFF: + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], self.min_temp, False + ) + else: + return + await self._adax_data_handler.update() + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement which this device uses.""" + return TEMP_CELSIUS + + @property + def min_temp(self) -> int: + """Return the minimum temperature.""" + return 5 + + @property + def max_temp(self) -> int: + """Return the maximum temperature.""" + return 35 + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._heater_data.get("temperature") + + @property + def target_temperature(self) -> int | None: + """Return the temperature we try to reach.""" + return self._heater_data.get("targetTemperature") + + @property + def target_temperature_step(self) -> int: + """Return the supported step of target temperature.""" + return PRECISION_WHOLE + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._adax_data_handler.set_room_target_temperature( + self._heater_data["id"], temperature, True + ) + + async def async_update(self) -> None: + """Get the latest data.""" + for room in await self._adax_data_handler.get_rooms(): + if room["id"] == self._heater_data["id"]: + self._heater_data = room + return diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py new file mode 100644 index 00000000000..cf845df5e06 --- /dev/null +++ b/homeassistant/components/adax/config_flow.py @@ -0,0 +1,73 @@ +"""Config flow for Adax integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import adax +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ACCOUNT_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(ACCOUNT_ID): int, vol.Required(CONF_PASSWORD): str} +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" + account_id = data[ACCOUNT_ID] + password = data[CONF_PASSWORD].replace(" ", "") + + token = await adax.get_adax_token( + async_get_clientsession(hass), account_id, password + ) + if token is None: + _LOGGER.info("Adax: Failed to login to retrieve token") + raise CannotConnect + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Adax.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + await self.async_set_unique_id(user_input[ACCOUNT_ID]) + self._abort_if_unique_id_configured() + + try: + await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[ACCOUNT_ID], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/adax/const.py b/homeassistant/components/adax/const.py new file mode 100644 index 00000000000..ecb83f9b0f7 --- /dev/null +++ b/homeassistant/components/adax/const.py @@ -0,0 +1,5 @@ +"""Constants for the Adax integration.""" +from typing import Final + +ACCOUNT_ID: Final = "account_id" +DOMAIN: Final = "adax" diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json new file mode 100644 index 00000000000..36106290ed6 --- /dev/null +++ b/homeassistant/components/adax/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "adax", + "name": "Adax", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adax", + "requirements": [ + "adax==0.0.1" + ], + "codeowners": [ + "@danielhiversen" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json new file mode 100644 index 00000000000..213e1f95cf9 --- /dev/null +++ b/homeassistant/components/adax/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "account_id": "Account ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/adax/translations/ca.json b/homeassistant/components/adax/translations/ca.json new file mode 100644 index 00000000000..85ba15804ac --- /dev/null +++ b/homeassistant/components/adax/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "account_id": "ID del compte", + "host": "Amfitri\u00f3", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/cs.json b/homeassistant/components/adax/translations/cs.json new file mode 100644 index 00000000000..ce5fa77543f --- /dev/null +++ b/homeassistant/components/adax/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/de.json b/homeassistant/components/adax/translations/de.json new file mode 100644 index 00000000000..414b373ff34 --- /dev/null +++ b/homeassistant/components/adax/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "account_id": "Konto-ID", + "host": "Host", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/en.json b/homeassistant/components/adax/translations/en.json new file mode 100644 index 00000000000..d1ef64a52c0 --- /dev/null +++ b/homeassistant/components/adax/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "account_id": "Account ID", + "host": "Host", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/et.json b/homeassistant/components/adax/translations/et.json new file mode 100644 index 00000000000..c8dd855218c --- /dev/null +++ b/homeassistant/components/adax/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamise viga" + }, + "step": { + "user": { + "data": { + "account_id": "Konto ID", + "host": "Host", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/fr.json b/homeassistant/components/adax/translations/fr.json new file mode 100644 index 00000000000..80164e30b54 --- /dev/null +++ b/homeassistant/components/adax/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" + }, + "step": { + "user": { + "data": { + "account_id": "identifiant de compte", + "host": "H\u00f4te", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/he.json b/homeassistant/components/adax/translations/he.json new file mode 100644 index 00000000000..54d31cd2669 --- /dev/null +++ b/homeassistant/components/adax/translations/he.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "account_id": "\u05de\u05d6\u05d4\u05d4 \u05d7\u05e9\u05d1\u05d5\u05df", + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/it.json b/homeassistant/components/adax/translations/it.json new file mode 100644 index 00000000000..c0ccf6aff05 --- /dev/null +++ b/homeassistant/components/adax/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "account_id": "ID account", + "host": "Host", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/nl.json b/homeassistant/components/adax/translations/nl.json new file mode 100644 index 00000000000..2bec9774c0a --- /dev/null +++ b/homeassistant/components/adax/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "account_id": "Account ID", + "host": "Host", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/pl.json b/homeassistant/components/adax/translations/pl.json new file mode 100644 index 00000000000..05d2f4a918c --- /dev/null +++ b/homeassistant/components/adax/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "user": { + "data": { + "account_id": "Identyfikator konta", + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/ru.json b/homeassistant/components/adax/translations/ru.json new file mode 100644 index 00000000000..d0aece78982 --- /dev/null +++ b/homeassistant/components/adax/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "account_id": "ID \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438", + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adax/translations/zh-Hant.json b/homeassistant/components/adax/translations/zh-Hant.json new file mode 100644 index 00000000000..9685227f617 --- /dev/null +++ b/homeassistant/components/adax/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "account_id": "\u5e33\u865f ID", + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/adguard/translations/de.json b/homeassistant/components/adguard/translations/de.json index f73c25d769e..b0a6b480249 100644 --- a/homeassistant/components/adguard/translations/de.json +++ b/homeassistant/components/adguard/translations/de.json @@ -17,7 +17,7 @@ "host": "Host", "password": "Passwort", "port": "Port", - "ssl": "AdGuard Home verwendet ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/adguard/translations/hu.json b/homeassistant/components/adguard/translations/hu.json index 251b72574ee..22fb5539bfa 100644 --- a/homeassistant/components/adguard/translations/hu.json +++ b/homeassistant/components/adguard/translations/hu.json @@ -1,9 +1,16 @@ { "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { + "hassio_confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant-ot, hogy csatlakozzon az AdGuard Home-hoz, amelyet a kieg\u00e9sz\u00edt\u0151 biztos\u00edt: {addon} ?", + "title": "Az AdGuard Home a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + }, "user": { "data": { "host": "Hoszt", diff --git a/homeassistant/components/adguard/translations/id.json b/homeassistant/components/adguard/translations/id.json index d787fd5620d..d3334997f59 100644 --- a/homeassistant/components/adguard/translations/id.json +++ b/homeassistant/components/adguard/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Layanan sudah dikonfigurasi", "existing_instance_updated": "Memperbarui konfigurasi yang ada." }, "error": { diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index b17a066eba7..d59d1e5aa0c 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -268,15 +268,17 @@ class AdsHub: class AdsEntity(Entity): """Representation of ADS entity.""" + _attr_should_poll = False + def __init__(self, ads_hub, name, ads_var): """Initialize ADS binary sensor.""" - self._name = name - self._unique_id = ads_var self._state_dict = {} self._state_dict[STATE_KEY_STATE] = None self._ads_hub = ads_hub self._ads_var = ads_var self._event = None + self._attr_unique_id = ads_var + self._attr_name = name async def async_initialize_device( self, ads_var, plctype, state_key=STATE_KEY_STATE, factor=None @@ -311,21 +313,6 @@ class AdsEntity(Entity): _LOGGER.debug("Variable %s: Timeout during first update", ads_var) @property - def name(self): - """Return the default name of the binary sensor.""" - return self._name - - @property - def unique_id(self): - """Return an unique identifier for this entity.""" - return self._unique_id - - @property - def should_poll(self): - """Return False because entity pushes its state to HA.""" - return False - - @property - def available(self): + def available(self) -> bool: """Return False if state has not been updated yet.""" return self._state_dict[STATE_KEY_STATE] is not None diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 0cf89dfa7cc..fda2aae3d5b 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -40,18 +40,13 @@ class AdsBinarySensor(AdsEntity, BinarySensorEntity): def __init__(self, ads_hub, name, ads_var, device_class): """Initialize ADS binary sensor.""" super().__init__(ads_hub, name, ads_var) - self._device_class = device_class or DEVICE_CLASS_MOVING + self._attr_device_class = device_class or DEVICE_CLASS_MOVING async def async_added_to_hass(self): """Register device notification.""" await self.async_initialize_device(self._ads_var, self._ads_hub.PLCTYPE_BOOL) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] - - @property - def device_class(self): - """Return the device class.""" - return self._device_class diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 5348873c7d0..0cd0264cb50 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -105,7 +105,12 @@ class AdsCover(AdsEntity, CoverEntity): self._ads_var_open = ads_var_open self._ads_var_close = ads_var_close self._ads_var_stop = ads_var_stop - self._device_class = device_class + self._attr_device_class = device_class + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + if ads_var_stop is not None: + self._attr_supported_features |= SUPPORT_STOP + if ads_var_pos_set is not None: + self._attr_supported_features |= SUPPORT_SET_POSITION async def async_added_to_hass(self): """Register device notification.""" @@ -119,11 +124,6 @@ class AdsCover(AdsEntity, CoverEntity): self._ads_var_position, self._ads_hub.PLCTYPE_BYTE, STATE_KEY_POSITION ) - @property - def device_class(self): - """Return the class of this cover.""" - return self._device_class - @property def is_closed(self): """Return if the cover is closed.""" @@ -138,19 +138,6 @@ class AdsCover(AdsEntity, CoverEntity): """Return current position of cover.""" return self._state_dict[STATE_KEY_POSITION] - @property - def supported_features(self): - """Flag supported features.""" - supported_features = SUPPORT_OPEN | SUPPORT_CLOSE - - if self._ads_var_stop is not None: - supported_features |= SUPPORT_STOP - - if self._ads_var_pos_set is not None: - supported_features |= SUPPORT_SET_POSITION - - return supported_features - def stop_cover(self, **kwargs): """Fire the stop action.""" if self._ads_var_stop: @@ -185,7 +172,7 @@ class AdsCover(AdsEntity, CoverEntity): self.set_cover_position(position=0) @property - def available(self): + def available(self) -> bool: """Return False if state has not been updated yet.""" if self._ads_var is not None or self._ads_var_position is not None: return ( diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 80ee5df0c4b..fd6b5e66482 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -1,4 +1,6 @@ """Support for ADS light sources.""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.light import ( @@ -48,6 +50,8 @@ class AdsLight(AdsEntity, LightEntity): super().__init__(ads_hub, name, ads_var_enable) self._state_dict[STATE_KEY_BRIGHTNESS] = None self._ads_var_brightness = ads_var_brightness + if ads_var_brightness is not None: + self._attr_supported_features = SUPPORT_BRIGHTNESS async def async_added_to_hass(self): """Register device notification.""" @@ -61,19 +65,12 @@ class AdsLight(AdsEntity, LightEntity): ) @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of the light (0..255).""" return self._state_dict[STATE_KEY_BRIGHTNESS] @property - def supported_features(self): - """Flag supported features.""" - if self._ads_var_brightness is not None: - return SUPPORT_BRIGHTNESS - return 0 - - @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 933950dcf1b..fe68c4c860b 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -5,6 +5,7 @@ from homeassistant.components import ads from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import StateType from . import CONF_ADS_FACTOR, CONF_ADS_TYPE, CONF_ADS_VAR, STATE_KEY_STATE, AdsEntity @@ -49,7 +50,7 @@ class AdsSensor(AdsEntity, SensorEntity): def __init__(self, ads_hub, ads_var, ads_type, name, unit_of_measurement, factor): """Initialize AdsSensor entity.""" super().__init__(ads_hub, name, ads_var) - self._unit_of_measurement = unit_of_measurement + self._attr_unit_of_measurement = unit_of_measurement self._ads_type = ads_type self._factor = factor @@ -63,11 +64,6 @@ class AdsSensor(AdsEntity, SensorEntity): ) @property - def state(self): + def state(self) -> StateType: """Return the state of the device.""" return self._state_dict[STATE_KEY_STATE] - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 9f807899e54..4888c876e1d 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -35,7 +35,7 @@ class AdsSwitch(AdsEntity, SwitchEntity): await self.async_initialize_device(self._ads_var, self._ads_hub.PLCTYPE_BOOL) @property - def is_on(self): + def is_on(self) -> bool: """Return True if the entity is on.""" return self._state_dict[STATE_KEY_STATE] diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index fba90148788..6a050e4086a 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -35,15 +35,13 @@ class AdvantageAirZoneFilter(AdvantageAirEntity, BinarySensorEntity): _attr_device_class = DEVICE_CLASS_PROBLEM - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Filter' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-filter' + def __init__(self, instance, ac_key): + """Initialize an Advantage Air Filter.""" + super().__init__(instance, ac_key) + self._attr_name = f'{self._ac["name"]} Filter' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-filter' + ) @property def is_on(self): @@ -56,15 +54,13 @@ class AdvantageAirZoneMotion(AdvantageAirEntity, BinarySensorEntity): _attr_device_class = DEVICE_CLASS_MOTION - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Motion' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-motion' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Motion.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} Motion' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-motion' + ) @property def is_on(self): @@ -77,15 +73,13 @@ class AdvantageAirZoneMyZone(AdvantageAirEntity, BinarySensorEntity): _attr_entity_registry_enabled_default = False - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} MyZone' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-myzone' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone MyZone.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} MyZone' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-myzone' + ) @property def is_on(self): diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index d890fa43207..1d377abc065 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -1,5 +1,4 @@ """Climate platform for Advantage Air integration.""" - from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( FAN_AUTO, @@ -16,6 +15,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS +from homeassistant.core import callback from homeassistant.helpers import entity_platform from .const import ( @@ -84,39 +84,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirClimateEntity(AdvantageAirEntity, ClimateEntity): """AdvantageAir Climate class.""" - @property - def temperature_unit(self): - """Return the temperature unit.""" - return TEMP_CELSIUS - - @property - def target_temperature_step(self): - """Return the supported temperature step.""" - return PRECISION_WHOLE - - @property - def max_temp(self): - """Return the maximum supported temperature.""" - return 32 - - @property - def min_temp(self): - """Return the minimum supported temperature.""" - return 16 + _attr_temperature_unit = TEMP_CELSIUS + _attr_target_temperature_step = PRECISION_WHOLE + _attr_max_temp = 32 + _attr_min_temp = 16 class AdvantageAirAC(AdvantageAirClimateEntity): """AdvantageAir AC unit.""" - @property - def name(self): - """Return the name.""" - return self._ac["name"] + _attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] + _attr_hvac_modes = AC_HVAC_MODES + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}' + def __init__(self, instance, ac_key): + """Initialize an AdvantageAir AC unit.""" + super().__init__(instance, ac_key) + self._attr_name = self._ac["name"] + self._attr_unique_id = f'{self.coordinator.data["system"]["rid"]}-{ac_key}' + if self._ac.get("myAutoModeEnabled"): + self._attr_hvac_modes = AC_HVAC_MODES + [HVAC_MODE_AUTO] @property def target_temperature(self): @@ -130,28 +117,11 @@ class AdvantageAirAC(AdvantageAirClimateEntity): return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"]) return HVAC_MODE_OFF - @property - def hvac_modes(self): - """Return the supported HVAC modes.""" - if self._ac.get("myAutoModeEnabled"): - return AC_HVAC_MODES + [HVAC_MODE_AUTO] - return AC_HVAC_MODES - @property def fan_mode(self): """Return the current fan modes.""" return ADVANTAGE_AIR_FAN_MODES.get(self._ac["fan"]) - @property - def fan_modes(self): - """Return the supported fan modes.""" - return [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH] - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE - async def async_set_hvac_mode(self, hvac_mode): """Set the HVAC Mode and State.""" if hvac_mode == HVAC_MODE_OFF: @@ -185,42 +155,30 @@ class AdvantageAirAC(AdvantageAirClimateEntity): class AdvantageAirZone(AdvantageAirClimateEntity): """AdvantageAir Zone control.""" - @property - def name(self): - """Return the name.""" - return self._zone["name"] + _attr_hvac_modes = ZONE_HVAC_MODES + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}' + def __init__(self, instance, ac_key, zone_key): + """Initialize an AdvantageAir Zone control.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = self._zone["name"] + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' + ) - @property - def current_temperature(self): - """Return the current temperature.""" - return self._zone["measuredTemp"] + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) - @property - def target_temperature(self): - """Return the target temperature.""" - return self._zone["setTemp"] - - @property - def hvac_mode(self): - """Return the current HVAC modes.""" + @callback + def _update_callback(self) -> None: + """Load data from integration.""" + self._attr_current_temperature = self._zone["measuredTemp"] + self._attr_target_temperature = self._zone["setTemp"] + self._attr_hvac_mode = HVAC_MODE_OFF if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: - return HVAC_MODE_FAN_ONLY - return HVAC_MODE_OFF - - @property - def hvac_modes(self): - """Return supported HVAC modes.""" - return ZONE_HVAC_MODES - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_TARGET_TEMPERATURE + self._attr_hvac_mode = HVAC_MODE_FAN_ONLY + self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode): """Set the HVAC Mode and State.""" diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 69d66849cd6..04960dab002 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -36,25 +36,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): """Advantage Air Cover Class.""" - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]}' + _attr_device_class = DEVICE_CLASS_DAMPER + _attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}' - - @property - def device_class(self): - """Return the device class of the vent.""" - return DEVICE_CLASS_DAMPER - - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Cover Class.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]}' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}' + ) @property def is_closed(self): diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index ea20368c10f..ffb75e78c01 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -14,6 +14,13 @@ class AdvantageAirEntity(CoordinatorEntity): self.async_change = instance["async_change"] self.ac_key = ac_key self.zone_key = zone_key + self._attr_device_info = { + "identifiers": {(DOMAIN, self.coordinator.data["system"]["rid"])}, + "name": self.coordinator.data["system"]["name"], + "manufacturer": "Advantage Air", + "model": self.coordinator.data["system"]["sysType"], + "sw_version": self.coordinator.data["system"]["myAppRev"], + } @property def _ac(self): @@ -22,14 +29,3 @@ class AdvantageAirEntity(CoordinatorEntity): @property def _zone(self): return self.coordinator.data["aircons"][self.ac_key]["zones"][self.zone_key] - - @property - def device_info(self): - """Return parent device information.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["system"]["rid"])}, - "name": self.coordinator.data["system"]["name"], - "manufacturer": "Advantage Air", - "model": self.coordinator.data["system"]["sysType"], - "sw_version": self.coordinator.data["system"]["myAppRev"], - } diff --git a/homeassistant/components/advantage_air/manifest.json b/homeassistant/components/advantage_air/manifest.json index 750d5457e17..6390ccea39c 100644 --- a/homeassistant/components/advantage_air/manifest.json +++ b/homeassistant/components/advantage_air/manifest.json @@ -3,8 +3,12 @@ "name": "Advantage Air", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/advantage_air", - "codeowners": ["@Bre77"], - "requirements": ["advantage_air==0.2.1"], + "codeowners": [ + "@Bre77" + ], + "requirements": [ + "advantage_air==0.2.5" + ], "quality_scale": "platinum", "iot_class": "local_polling" -} +} \ No newline at end of file diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 8c6834ac76e..eca7651d6eb 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,8 +1,8 @@ """Sensor platform for Advantage Air integration.""" import voluptuous as vol -from homeassistant.components.sensor import SensorEntity -from homeassistant.const import PERCENTAGE +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv, entity_platform from .const import ADVANTAGE_AIR_STATE_OPEN, DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -25,9 +25,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) for zone_key, zone in ac_device["zones"].items(): - # Only show damper sensors when zone is in temperature control + # Only show damper and temp sensors when zone is in temperature control if zone["type"] != 0: entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) + entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key)) # Only show wireless signal strength sensors when using wireless sensors if zone["rssi"] > 0: entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key)) @@ -50,17 +51,11 @@ class AdvantageAirTimeTo(AdvantageAirEntity, SensorEntity): """Initialize the Advantage Air timer control.""" super().__init__(instance, ac_key) self.action = action - self._time_key = f"countDownTo{self.action}" - - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Time To {self.action}' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-timeto{self.action}' + self._time_key = f"countDownTo{action}" + self._attr_name = f'{self._ac["name"]} Time To {action}' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-timeto{action}' + ) @property def state(self): @@ -84,16 +79,15 @@ class AdvantageAirZoneVent(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone Vent Sensor.""" _attr_unit_of_measurement = PERCENTAGE + _attr_state_class = STATE_CLASS_MEASUREMENT - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Vent' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-vent' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Vent Sensor.""" + super().__init__(instance, ac_key, zone_key=zone_key) + self._attr_name = f'{self._zone["name"]} Vent' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-vent' + ) @property def state(self): @@ -114,16 +108,15 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): """Representation of Advantage Air Zone wireless signal sensor.""" _attr_unit_of_measurement = PERCENTAGE + _attr_state_class = STATE_CLASS_MEASUREMENT - @property - def name(self): - """Return the name.""" - return f'{self._zone["name"]} Signal' - - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-signal' + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone wireless signal sensor.""" + super().__init__(instance, ac_key, zone_key=zone_key) + self._attr_name = f'{self._zone["name"]} Signal' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-{zone_key}-signal' + ) @property def state(self): @@ -142,3 +135,23 @@ class AdvantageAirZoneSignal(AdvantageAirEntity, SensorEntity): if self._zone["rssi"] >= 20: return "mdi:wifi-strength-1" return "mdi:wifi-strength-outline" + + +class AdvantageAirZoneTemp(AdvantageAirEntity, SensorEntity): + """Representation of Advantage Air Zone wireless signal sensor.""" + + _attr_unit_of_measurement = TEMP_CELSIUS + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_icon = "mdi:thermometer" + _attr_entity_registry_enabled_default = False + + def __init__(self, instance, ac_key, zone_key): + """Initialize an Advantage Air Zone Temp Sensor.""" + super().__init__(instance, ac_key, zone_key) + self._attr_name = f'{self._zone["name"]} Temperature' + self._attr_unique_id = f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-{self.zone_key}-temp' + + @property + def state(self): + """Return the current value of the measured temperature.""" + return self._zone["measuredTemp"] diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 6c687c1427e..1a44973f8c1 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -25,26 +25,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AdvantageAirFreshAir(AdvantageAirEntity, ToggleEntity): """Representation of Advantage Air fresh air control.""" - @property - def name(self): - """Return the name.""" - return f'{self._ac["name"]} Fresh Air' + _attr_icon = "mdi:air-filter" - @property - def unique_id(self): - """Return a unique id.""" - return f'{self.coordinator.data["system"]["rid"]}-{self.ac_key}-freshair' + def __init__(self, instance, ac_key): + """Initialize an Advantage Air fresh air control.""" + super().__init__(instance, ac_key) + self._attr_name = f'{self._ac["name"]} Fresh Air' + self._attr_unique_id = ( + f'{self.coordinator.data["system"]["rid"]}-{ac_key}-freshair' + ) @property def is_on(self): """Return the fresh air status.""" return self._ac["freshAirStatus"] == ADVANTAGE_AIR_STATE_ON - @property - def icon(self): - """Return a representative icon of the fresh air switch.""" - return "mdi:air-filter" - async def async_turn_on(self, **kwargs): """Turn fresh air on.""" await self.async_change( diff --git a/homeassistant/components/advantage_air/translations/de.json b/homeassistant/components/advantage_air/translations/de.json index c761ac5c6be..0d3ead73fc0 100644 --- a/homeassistant/components/advantage_air/translations/de.json +++ b/homeassistant/components/advantage_air/translations/de.json @@ -9,10 +9,10 @@ "step": { "user": { "data": { - "ip_address": "IP Adresse", + "ip_address": "IP-Adresse", "port": "Port" }, - "description": "Anschluss an die API Ihres Advantage Air Wandtabletts.", + "description": "Anschluss an die API deines Advantage Air Wandtabletts.", "title": "Verbinden" } } diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index de7b06347c3..3fd0769cb00 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -66,6 +66,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AbstractAemetSensor(CoordinatorEntity, SensorEntity): """Abstract class for an AEMET OpenData sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__( self, name, @@ -80,33 +82,10 @@ class AbstractAemetSensor(CoordinatorEntity, SensorEntity): self._unique_id = unique_id self._sensor_type = sensor_type self._sensor_name = sensor_configuration[SENSOR_NAME] - self._unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) - self._device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name}" - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - - @property - def device_class(self): - """Return the device_class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} + self._attr_name = f"{self._name} {self._sensor_name}" + self._attr_unique_id = self._unique_id + self._attr_device_class = sensor_configuration.get(SENSOR_DEVICE_CLASS) + self._attr_unit_of_measurement = sensor_configuration.get(SENSOR_UNIT) class AemetSensor(AbstractAemetSensor): @@ -150,11 +129,9 @@ class AemetForecastSensor(AbstractAemetSensor): ) self._weather_coordinator = weather_coordinator self._forecast_mode = forecast_mode - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._forecast_mode == FORECAST_MODE_DAILY + self._attr_entity_registry_enabled_default = ( + self._forecast_mode == FORECAST_MODE_DAILY + ) @property def state(self): diff --git a/homeassistant/components/aemet/translations/ar.json b/homeassistant/components/aemet/translations/ar.json new file mode 100644 index 00000000000..68ba2eda2f2 --- /dev/null +++ b/homeassistant/components/aemet/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "station_updates": "\u062c\u0645\u0639 \u0627\u0644\u0628\u064a\u0627\u0646\u0627\u062a \u0645\u0646 \u0645\u062d\u0637\u0627\u062a \u0627\u0644\u0637\u0642\u0633 AEMET" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/de.json b/homeassistant/components/aemet/translations/de.json index 2a4a927b90a..0704e7d71ba 100644 --- a/homeassistant/components/aemet/translations/de.json +++ b/homeassistant/components/aemet/translations/de.json @@ -15,7 +15,7 @@ "name": "Name der Integration" }, "description": "Richte die AEMET OpenData Integration ein. Um den API-Schl\u00fcssel zu generieren, besuche https://opendata.aemet.es/centrodedescargas/altaUsuario", - "title": "[void]" + "title": "AEMET OpenData" } } }, diff --git a/homeassistant/components/aemet/translations/fr.json b/homeassistant/components/aemet/translations/fr.json index bb1e792aa5e..4ad76320f03 100644 --- a/homeassistant/components/aemet/translations/fr.json +++ b/homeassistant/components/aemet/translations/fr.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Recueillir les donn\u00e9es des stations m\u00e9t\u00e9orologiques AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/hu.json b/homeassistant/components/aemet/translations/hu.json index d810691046e..31a7654efd9 100644 --- a/homeassistant/components/aemet/translations/hu.json +++ b/homeassistant/components/aemet/translations/hu.json @@ -14,8 +14,18 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "Az integr\u00e1ci\u00f3 neve" }, + "description": "\u00c1ll\u00edtsa be az AEMET OpenData integr\u00e1ci\u00f3t. Az API-kulcs el\u0151\u00e1ll\u00edt\u00e1s\u00e1hoz keresse fel a https://opendata.aemet.es/centrodedescargas/altaUsuario webhelyet.", "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Gy\u0171jts\u00f6n adatokat az AEMET meteorol\u00f3giai \u00e1llom\u00e1sokr\u00f3l" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/id.json b/homeassistant/components/aemet/translations/id.json index fa678cbbbe0..e3a602a9a7c 100644 --- a/homeassistant/components/aemet/translations/id.json +++ b/homeassistant/components/aemet/translations/id.json @@ -18,5 +18,14 @@ "title": "AEMET OpenData" } } + }, + "options": { + "step": { + "init": { + "data": { + "station_updates": "Kumpulkan data dari stasiun cuaca AEMET" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index e54a297cc09..07bb0bfba83 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -39,6 +39,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AemetWeather(CoordinatorEntity, WeatherEntity): """Implementation of an AEMET OpenData sensor.""" + _attr_attribution = ATTRIBUTION + _attr_temperature_unit = TEMP_CELSIUS + def __init__( self, name, @@ -48,25 +51,18 @@ class AemetWeather(CoordinatorEntity, WeatherEntity): ): """Initialize the sensor.""" super().__init__(coordinator) - self._name = name - self._unique_id = unique_id self._forecast_mode = forecast_mode - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION + self._attr_entity_registry_enabled_default = ( + self._forecast_mode == FORECAST_MODE_DAILY + ) + self._attr_name = name + self._attr_unique_id = unique_id @property def condition(self): """Return the current condition.""" return self.coordinator.data[ATTR_API_CONDITION] - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._forecast_mode == FORECAST_MODE_DAILY - @property def forecast(self): """Return the forecast array.""" @@ -77,11 +73,6 @@ class AemetWeather(CoordinatorEntity, WeatherEntity): """Return the humidity.""" return self.coordinator.data[ATTR_API_HUMIDITY] - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def pressure(self): """Return the pressure.""" @@ -92,16 +83,6 @@ class AemetWeather(CoordinatorEntity, WeatherEntity): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._unique_id - @property def wind_bearing(self): """Return the temperature.""" diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index 4d3fb17b949..fd8e095f65f 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -109,38 +109,26 @@ async def async_setup_platform( class AfterShipSensor(SensorEntity): """Representation of a AfterShip sensor.""" + _attr_unit_of_measurement: str = "packages" + _attr_icon: str = ICON + def __init__(self, aftership: Tracking, name: str) -> None: """Initialize the sensor.""" self._attributes: dict[str, Any] = {} - self._name: str = name self._state: int | None = None self.aftership = aftership - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name + self._attr_name = name @property def state(self) -> int | None: """Return the state of the sensor.""" return self._state - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return "packages" - @property def extra_state_attributes(self) -> dict[str, str]: """Return attributes for the sensor.""" return self._attributes - @property - def icon(self) -> str: - """Icon to use in the frontend.""" - return ICON - async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 3e093ae46a8..8f139af8963 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -35,90 +35,60 @@ async def async_setup_entry( class AgentBaseStation(AlarmControlPanelEntity): """Representation of an Agent DVR Alarm Control Panel.""" + _attr_icon = ICON + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + def __init__(self, client): """Initialize the alarm control panel.""" - self._state = None self._client = client - self._unique_id = f"{client.unique}_CP" - name = CONST_ALARM_CONTROL_PANEL_NAME - self._name = name = f"{client.name} {name}" - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - - @property - def device_info(self): - """Return the device info for adding the entity to the agent object.""" - return { - "identifiers": {(AGENT_DOMAIN, self._client.unique)}, + self._attr_name = f"{client.name} {CONST_ALARM_CONTROL_PANEL_NAME}" + self._attr_unique_id = f"{client.unique}_CP" + self._attr_device_info = { + "identifiers": {(AGENT_DOMAIN, client.unique)}, "manufacturer": "Agent", "model": CONST_ALARM_CONTROL_PANEL_NAME, - "sw_version": self._client.version, + "sw_version": client.version, } async def async_update(self): """Update the state of the device.""" await self._client.update() + self._attr_available = self._client.is_available armed = self._client.is_armed if armed is None: - self._state = None + self._attr_state = None return if armed: prof = (await self._client.get_active_profile()).lower() - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY if prof == CONF_HOME_MODE_NAME: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME elif prof == CONF_NIGHT_MODE_NAME: - self._state = STATE_ALARM_ARMED_NIGHT + self._attr_state = STATE_ALARM_ARMED_NIGHT else: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED async def async_alarm_disarm(self, code=None): """Send disarm command.""" await self._client.disarm() - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED async def async_alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_AWAY_MODE_NAME) - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY async def async_alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_HOME_MODE_NAME) - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME async def async_alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_NIGHT_MODE_NAME) - self._state = STATE_ALARM_ARMED_NIGHT - - @property - def name(self): - """Return the name of the base station.""" - return self._name - - @property - def available(self) -> bool: - """Device available.""" - return self._client.is_available - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id + self._attr_state = STATE_ALARM_ARMED_NIGHT diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index 6b2363f50d5..30c27eb047a 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -67,31 +67,27 @@ async def async_setup_entry( class AgentCamera(MjpegCamera): """Representation of an Agent Device Stream.""" + _attr_supported_features = SUPPORT_ON_OFF + def __init__(self, device): """Initialize as a subclass of MjpegCamera.""" - self._servername = device.client.name - self.server_url = device.client._server_url - device_info = { CONF_NAME: device.name, - CONF_MJPEG_URL: f"{self.server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", - CONF_STILL_IMAGE_URL: f"{self.server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", + CONF_MJPEG_URL: f"{device.client._server_url}{device.mjpeg_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", + CONF_STILL_IMAGE_URL: f"{device.client._server_url}{device.still_image_url}&size={device.mjpegStreamWidth}x{device.mjpegStreamHeight}", } self.device = device self._removed = False - self._name = f"{self._servername} {device.name}" - self._unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" + self._attr_name = f"{device.client.name} {device.name}" + self._attr_unique_id = f"{device._client.unique}_{device.typeID}_{device.id}" + self._attr_should_poll = True super().__init__(device_info) - - @property - def device_info(self): - """Return the device info for adding the entity to the agent object.""" - return { - "identifiers": {(AGENT_DOMAIN, self._unique_id)}, - "name": self._name, + self._attr_device_info = { + "identifiers": {(AGENT_DOMAIN, self.unique_id)}, + "name": self.name, "manufacturer": "Agent", "model": "Camera", - "sw_version": self.device.client.version, + "sw_version": device.client.version, } async def async_update(self): @@ -99,18 +95,18 @@ class AgentCamera(MjpegCamera): try: await self.device.update() if self._removed: - _LOGGER.debug("%s reacquired", self._name) + _LOGGER.debug("%s reacquired", self.name) self._removed = False except AgentError: # server still available - camera error if self.device.client.is_available and not self._removed: - _LOGGER.error("%s lost", self._name) + _LOGGER.error("%s lost", self.name) self._removed = True - - @property - def extra_state_attributes(self): - """Return the Agent DVR camera state attributes.""" - return { + self._attr_available = self.device.client.is_available + self._attr_icon = "mdi:camcorder-off" + if self.is_on: + self._attr_icon = "mdi:camcorder" + self._attr_extra_state_attributes = { ATTR_ATTRIBUTION: ATTRIBUTION, "editable": False, "enabled": self.is_on, @@ -121,11 +117,6 @@ class AgentCamera(MjpegCamera): "alerts_enabled": self.device.alerts_active, } - @property - def should_poll(self) -> bool: - """Update the state periodically.""" - return True - @property def is_recording(self) -> bool: """Return whether the monitor is recording.""" @@ -141,43 +132,21 @@ class AgentCamera(MjpegCamera): """Return whether the monitor has alerted.""" return self.device.detected - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self.device.client.is_available - @property def connected(self) -> bool: """Return True if entity is connected.""" return self.device.connected - @property - def supported_features(self) -> int: - """Return supported features.""" - return SUPPORT_ON_OFF - @property def is_on(self) -> bool: """Return true if on.""" return self.device.online - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - if self.is_on: - return "mdi:camcorder" - return "mdi:camcorder-off" - @property def motion_detection_enabled(self): """Return the camera motion detection status.""" return self.device.detector_active - @property - def unique_id(self) -> str: - """Return a unique identifier for this agent object.""" - return self._unique_id - async def async_enable_alerts(self): """Enable alerts.""" await self.device.alerts_on() diff --git a/homeassistant/components/agent_dvr/translations/de.json b/homeassistant/components/agent_dvr/translations/de.json index 10a8307ada1..a8c31dd0ca8 100644 --- a/homeassistant/components/agent_dvr/translations/de.json +++ b/homeassistant/components/agent_dvr/translations/de.json @@ -13,7 +13,7 @@ "host": "Host", "port": "Port" }, - "title": "Richten Sie den Agent DVR ein" + "title": "Richte den Agent DVR ein" } } } diff --git a/homeassistant/components/agent_dvr/translations/he.json b/homeassistant/components/agent_dvr/translations/he.json index e50d45e5608..d37b99a2f45 100644 --- a/homeassistant/components/agent_dvr/translations/he.json +++ b/homeassistant/components/agent_dvr/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u05d4\u05de\u05db\u05e9\u05d9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index d79a33a66ab..157f28c33f7 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -3,10 +3,8 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -16,7 +14,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) -from .model import SensorDescription +from .model import AirlySensorEntityDescription ATTR_API_ADVICE: Final = "ADVICE" ATTR_API_CAQI: Final = "CAQI" @@ -31,12 +29,9 @@ ATTR_API_TEMPERATURE: Final = "TEMPERATURE" ATTR_ADVICE: Final = "advice" ATTR_DESCRIPTION: Final = "description" -ATTR_LABEL: Final = "label" ATTR_LEVEL: Final = "level" ATTR_LIMIT: Final = "limit" ATTR_PERCENT: Final = "percent" -ATTR_UNIT: Final = "unit" -ATTR_VALUE: Final = "value" SUFFIX_PERCENT: Final = "PERCENT" SUFFIX_LIMIT: Final = "LIMIT" @@ -51,52 +46,54 @@ MAX_UPDATE_INTERVAL: Final = 90 MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." -SENSOR_TYPES: dict[str, SensorDescription] = { - ATTR_API_CAQI: { - ATTR_LABEL: ATTR_API_CAQI, - ATTR_UNIT: "CAQI", - ATTR_VALUE: round, - }, - ATTR_API_PM1: { - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_PM1, - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: round, - }, - ATTR_API_PM25: { - ATTR_ICON: "mdi:blur", - ATTR_LABEL: "PM2.5", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: round, - }, - ATTR_API_PM10: { - ATTR_ICON: "mdi:blur", - ATTR_LABEL: ATTR_API_PM10, - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: round, - }, - ATTR_API_HUMIDITY: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(), - ATTR_UNIT: PERCENTAGE, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: lambda value: round(value, 1), - }, - ATTR_API_PRESSURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_LABEL: ATTR_API_PRESSURE.capitalize(), - ATTR_UNIT: PRESSURE_HPA, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: round, - }, - ATTR_API_TEMPERATURE: { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_LABEL: ATTR_API_TEMPERATURE.capitalize(), - ATTR_UNIT: TEMP_CELSIUS, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_VALUE: lambda value: round(value, 1), - }, -} +SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( + AirlySensorEntityDescription( + key=ATTR_API_CAQI, + name=ATTR_API_CAQI, + unit_of_measurement="CAQI", + ), + AirlySensorEntityDescription( + key=ATTR_API_PM1, + icon="mdi:blur", + name=ATTR_API_PM1, + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM25, + icon="mdi:blur", + name="PM2.5", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_PM10, + icon="mdi:blur", + name=ATTR_API_PM10, + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_HUMIDITY, + device_class=DEVICE_CLASS_HUMIDITY, + name=ATTR_API_HUMIDITY.capitalize(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value, 1), + ), + AirlySensorEntityDescription( + key=ATTR_API_PRESSURE, + device_class=DEVICE_CLASS_PRESSURE, + name=ATTR_API_PRESSURE.capitalize(), + unit_of_measurement=PRESSURE_HPA, + state_class=STATE_CLASS_MEASUREMENT, + ), + AirlySensorEntityDescription( + key=ATTR_API_TEMPERATURE, + device_class=DEVICE_CLASS_TEMPERATURE, + name=ATTR_API_TEMPERATURE.capitalize(), + unit_of_measurement=TEMP_CELSIUS, + state_class=STATE_CLASS_MEASUREMENT, + value=lambda value: round(value, 1), + ), +) diff --git a/homeassistant/components/airly/model.py b/homeassistant/components/airly/model.py index fe8ad6c929b..38b433de34c 100644 --- a/homeassistant/components/airly/model.py +++ b/homeassistant/components/airly/model.py @@ -1,15 +1,14 @@ """Type definitions for Airly integration.""" from __future__ import annotations -from typing import Callable, TypedDict +from dataclasses import dataclass +from typing import Callable + +from homeassistant.components.sensor import SensorEntityDescription -class SensorDescription(TypedDict, total=False): - """Sensor description class.""" +@dataclass +class AirlySensorEntityDescription(SensorEntityDescription): + """Class describing Airly sensor entities.""" - device_class: str | None - icon: str | None - label: str - unit: str - state_class: str | None - value: Callable + value: Callable = round diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 3f9048dd03e..2c811b00aa6 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -3,16 +3,10 @@ from __future__ import annotations from typing import Any, cast -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, - ATTR_ICON, - CONF_NAME, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -27,12 +21,9 @@ from .const import ( ATTR_API_PM10, ATTR_API_PM25, ATTR_DESCRIPTION, - ATTR_LABEL, ATTR_LEVEL, ATTR_LIMIT, ATTR_PERCENT, - ATTR_UNIT, - ATTR_VALUE, ATTRIBUTION, DEFAULT_NAME, DOMAIN, @@ -41,6 +32,7 @@ from .const import ( SUFFIX_LIMIT, SUFFIX_PERCENT, ) +from .model import AirlySensorEntityDescription PARALLEL_UPDATES = 1 @@ -54,10 +46,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id] sensors = [] - for sensor in SENSOR_TYPES: + for description in SENSOR_TYPES: # When we use the nearest method, we are not sure which sensors are available - if coordinator.data.get(sensor): - sensors.append(AirlySensor(coordinator, name, sensor)) + if coordinator.data.get(description.key): + sensors.append(AirlySensor(coordinator, name, description)) async_add_entities(sensors, False) @@ -66,47 +58,54 @@ class AirlySensor(CoordinatorEntity, SensorEntity): """Define an Airly sensor.""" coordinator: AirlyDataUpdateCoordinator + entity_description: AirlySensorEntityDescription def __init__( - self, coordinator: AirlyDataUpdateCoordinator, name: str, kind: str + self, + coordinator: AirlyDataUpdateCoordinator, + name: str, + description: AirlySensorEntityDescription, ) -> None: """Initialize.""" super().__init__(coordinator) - self._description = description = SENSOR_TYPES[kind] - self._attr_device_class = description.get(ATTR_DEVICE_CLASS) - self._attr_icon = description.get(ATTR_ICON) - self._attr_name = f"{name} {description[ATTR_LABEL]}" - self._attr_state_class = description.get(ATTR_STATE_CLASS) + self._attr_device_info = { + "identifiers": { + (DOMAIN, f"{coordinator.latitude}-{coordinator.longitude}") + }, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + self._attr_name = f"{name} {description.name}" self._attr_unique_id = ( - f"{coordinator.latitude}-{coordinator.longitude}-{kind.lower()}" + f"{coordinator.latitude}-{coordinator.longitude}-{description.key}".lower() ) - self._attr_unit_of_measurement = description.get(ATTR_UNIT) self._attrs: dict[str, Any] = {ATTR_ATTRIBUTION: ATTRIBUTION} - self.kind = kind + self.entity_description = description @property def state(self) -> StateType: """Return the state.""" - state = self.coordinator.data[self.kind] - return cast(StateType, self._description[ATTR_VALUE](state)) + state = self.coordinator.data[self.entity_description.key] + return cast(StateType, self.entity_description.value(state)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if self.kind == ATTR_API_CAQI: + if self.entity_description.key == ATTR_API_CAQI: self._attrs[ATTR_LEVEL] = self.coordinator.data[ATTR_API_CAQI_LEVEL] self._attrs[ATTR_ADVICE] = self.coordinator.data[ATTR_API_ADVICE] self._attrs[ATTR_DESCRIPTION] = self.coordinator.data[ ATTR_API_CAQI_DESCRIPTION ] - if self.kind == ATTR_API_PM25: + if self.entity_description.key == ATTR_API_PM25: self._attrs[ATTR_LIMIT] = self.coordinator.data[ f"{ATTR_API_PM25}_{SUFFIX_LIMIT}" ] self._attrs[ATTR_PERCENT] = round( self.coordinator.data[f"{ATTR_API_PM25}_{SUFFIX_PERCENT}"] ) - if self.kind == ATTR_API_PM10: + if self.entity_description.key == ATTR_API_PM10: self._attrs[ATTR_LIMIT] = self.coordinator.data[ f"{ATTR_API_PM10}_{SUFFIX_LIMIT}" ] @@ -114,18 +113,3 @@ class AirlySensor(CoordinatorEntity, SensorEntity): self.coordinator.data[f"{ATTR_API_PM10}_{SUFFIX_PERCENT}"] ) return self._attrs - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": { - ( - DOMAIN, - f"{self.coordinator.latitude}-{self.coordinator.longitude}", - ) - }, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } diff --git a/homeassistant/components/airly/translations/hu.json b/homeassistant/components/airly/translations/hu.json index b9fd0c9e05c..f730edde85f 100644 --- a/homeassistant/components/airly/translations/hu.json +++ b/homeassistant/components/airly/translations/hu.json @@ -22,6 +22,7 @@ }, "system_health": { "info": { + "can_reach_server": "\u00c9rje el az Airly szervert", "requests_per_day": "Enged\u00e9lyezett k\u00e9r\u00e9sek naponta", "requests_remaining": "Fennmarad\u00f3 enged\u00e9lyezett k\u00e9r\u00e9sek" } diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 2d3adc8d1e2..31ea5e298e3 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -67,16 +67,13 @@ class AirNowSensor(CoordinatorEntity, SensorEntity): """Initialize.""" super().__init__(coordinator) self.kind = kind - self._device_class = None self._state = None - self._icon = None - self._unit_of_measurement = None self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - - @property - def name(self): - """Return the name.""" - return f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + self._attr_name = f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}" + self._attr_icon = SENSOR_TYPES[self.kind][ATTR_ICON] + self._attr_device_class = SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] + self._attr_unit_of_measurement = SENSOR_TYPES[self.kind][ATTR_UNIT] + self._attr_unique_id = f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" @property def state(self): @@ -96,24 +93,3 @@ class AirNowSensor(CoordinatorEntity, SensorEntity): ] return self._attrs - - @property - def icon(self): - """Return the icon.""" - self._icon = SENSOR_TYPES[self.kind][ATTR_ICON] - return self._icon - - @property - def device_class(self): - """Return the device_class.""" - return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS] - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}" - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return SENSOR_TYPES[self.kind][ATTR_UNIT] diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index a73ad6d179c..9fc5bd3bccc 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -1,5 +1,4 @@ { - "title": "AirNow", "config": { "step": { "user": { diff --git a/homeassistant/components/airnow/translations/de.json b/homeassistant/components/airnow/translations/de.json index 646369b6b61..adf9ddf85a3 100644 --- a/homeassistant/components/airnow/translations/de.json +++ b/homeassistant/components/airnow/translations/de.json @@ -17,7 +17,7 @@ "longitude": "L\u00e4ngengrad", "radius": "Stationsradius (Meilen; optional)" }, - "description": "Richten Sie die AirNow-Luftqualit\u00e4tsintegration ein. Um den API-Schl\u00fcssel zu generieren, besuchen Sie https://docs.airnowapi.org/account/request/.", + "description": "Richte die AirNow-Luftqualit\u00e4tsintegration ein. Um den API-Schl\u00fcssel zu generieren, besuche https://docs.airnowapi.org/account/request/.", "title": "AirNow" } } diff --git a/homeassistant/components/airnow/translations/hu.json b/homeassistant/components/airnow/translations/hu.json index 418450f2419..3f1bef471ee 100644 --- a/homeassistant/components/airnow/translations/hu.json +++ b/homeassistant/components/airnow/translations/hu.json @@ -14,8 +14,10 @@ "data": { "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", - "longitude": "Hossz\u00fas\u00e1g" + "longitude": "Hossz\u00fas\u00e1g", + "radius": "\u00c1llom\u00e1s sugara (m\u00e9rf\u00f6ld; opcion\u00e1lis)" }, + "description": "\u00c1ll\u00edtsa be az AirNow leveg\u0151min\u0151s\u00e9gi integr\u00e1ci\u00f3t. Az API-kulcs el\u0151\u00e1ll\u00edt\u00e1s\u00e1hoz keresse fel a https://docs.airnowapi.org/account/request/ oldalt.", "title": "AirNow" } } diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 8a1e0ad9655..c44e39b59e4 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,6 +1,10 @@ """The airvisual component.""" +from __future__ import annotations + +from collections.abc import Mapping from datetime import timedelta from math import ceil +from typing import Any from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -10,6 +14,7 @@ from pyairvisual.errors import ( NodeProError, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -20,9 +25,13 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + entity_registry, +) from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -42,7 +51,7 @@ from .const import ( LOGGER, ) -PLATFORMS = ["air_quality", "sensor"] +PLATFORMS = ["sensor"] DATA_LISTENER = "listener" @@ -53,11 +62,8 @@ CONFIG_SCHEMA = cv.deprecated(DOMAIN) @callback -def async_get_geography_id(geography_dict): +def async_get_geography_id(geography_dict: Mapping[str, Any]) -> str: """Generate a unique ID from a geography dict.""" - if not geography_dict: - return - if CONF_CITY in geography_dict: return ", ".join( ( @@ -72,7 +78,9 @@ def async_get_geography_id(geography_dict): @callback -def async_get_cloud_api_update_interval(hass, api_key, num_consumers): +def async_get_cloud_api_update_interval( + hass: HomeAssistant, api_key: str, num_consumers: int +) -> timedelta: """Get a leveled scan interval for a particular cloud API key. This will shift based on the number of active consumers, thus keeping the user @@ -93,18 +101,22 @@ def async_get_cloud_api_update_interval(hass, api_key, num_consumers): @callback -def async_get_cloud_coordinators_by_api_key(hass, api_key): +def async_get_cloud_coordinators_by_api_key( + hass: HomeAssistant, api_key: str +) -> list[DataUpdateCoordinator]: """Get all DataUpdateCoordinator objects related to a particular API key.""" coordinators = [] for entry_id, coordinator in hass.data[DOMAIN][DATA_COORDINATOR].items(): config_entry = hass.config_entries.async_get_entry(entry_id) - if config_entry.data.get(CONF_API_KEY) == api_key: + if config_entry and config_entry.data.get(CONF_API_KEY) == api_key: coordinators.append(coordinator) return coordinators @callback -def async_sync_geo_coordinator_update_intervals(hass, api_key): +def async_sync_geo_coordinator_update_intervals( + hass: HomeAssistant, api_key: str +) -> None: """Sync the update interval for geography-based data coordinators (by API key).""" coordinators = async_get_cloud_coordinators_by_api_key(hass, api_key) @@ -124,14 +136,10 @@ def async_sync_geo_coordinator_update_intervals(hass, api_key): coordinator.update_interval = update_interval -async def async_setup(hass, config): - """Set up the AirVisual component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}, DATA_LISTENER: {}} - return True - - @callback -def _standardize_geography_config_entry(hass, config_entry): +def _standardize_geography_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Ensure that geography config entries have appropriate properties.""" entry_updates = {} @@ -164,9 +172,11 @@ def _standardize_geography_config_entry(hass, config_entry): @callback -def _standardize_node_pro_config_entry(hass, config_entry): +def _standardize_node_pro_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: """Ensure that Node/Pro config entries have appropriate properties.""" - entry_updates = {} + entry_updates: dict[str, Any] = {} if CONF_INTEGRATION_TYPE not in config_entry.data: # If the config entry data doesn't contain the integration type, add it: @@ -181,15 +191,17 @@ def _standardize_node_pro_config_entry(hass, config_entry): hass.config_entries.async_update_entry(config_entry, **entry_updates) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up AirVisual as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}, DATA_LISTENER: {}}) + if CONF_API_KEY in config_entry.data: _standardize_geography_config_entry(hass, config_entry) websession = aiohttp_client.async_get_clientsession(hass) cloud_api = CloudAPI(config_entry.data[CONF_API_KEY], session=websession) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" if CONF_CITY in config_entry.data: api_coro = cloud_api.air_quality.city( @@ -227,9 +239,22 @@ async def async_setup_entry(hass, config_entry): config_entry.entry_id ] = config_entry.add_update_listener(async_reload_entry) else: + # Remove outdated air_quality entities from the entity registry if they exist: + ent_reg = entity_registry.async_get(hass) + for entity_entry in [ + e + for e in ent_reg.entities.values() + if e.config_entry_id == config_entry.entry_id + and e.entity_id.startswith("air_quality") + ]: + LOGGER.debug( + 'Removing deprecated air_quality entity: "%s"', entity_entry.entity_id + ) + ent_reg.async_remove(entity_entry.entity_id) + _standardize_node_pro_config_entry(hass, config_entry) - async def async_update_data(): + async def async_update_data() -> dict[str, Any]: """Get new data from the API.""" try: async with NodeSamba( @@ -262,7 +287,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate an old config entry.""" version = config_entry.version @@ -304,7 +329,7 @@ async def async_migrate_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload an AirVisual config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -325,7 +350,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -async def async_reload_entry(hass, config_entry): +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -333,21 +358,17 @@ async def async_reload_entry(hass, config_entry): class AirVisualEntity(CoordinatorEntity): """Define a generic AirVisual entity.""" - def __init__(self, coordinator): + def __init__(self, coordinator: DataUpdateCoordinator) -> None: """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" self.update_from_latest_data() self.async_write_ha_state() @@ -357,6 +378,6 @@ class AirVisualEntity(CoordinatorEntity): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/airvisual/air_quality.py b/homeassistant/components/airvisual/air_quality.py deleted file mode 100644 index 175c129068f..00000000000 --- a/homeassistant/components/airvisual/air_quality.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Support for AirVisual Node/Pro units.""" -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.core import callback - -from . import AirVisualEntity -from .const import ( - CONF_INTEGRATION_TYPE, - DATA_COORDINATOR, - DOMAIN, - INTEGRATION_TYPE_NODE_PRO, -) - -ATTR_HUMIDITY = "humidity" -ATTR_SENSOR_LIFE = "{0}_sensor_life" -ATTR_VOC = "voc" - - -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up AirVisual air quality entities based on a config entry.""" - # Geography-based AirVisual integrations don't utilize this platform: - if config_entry.data[CONF_INTEGRATION_TYPE] != INTEGRATION_TYPE_NODE_PRO: - return - - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] - - async_add_entities([AirVisualNodeProSensor(coordinator)], True) - - -class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity): - """Define a sensor for a AirVisual Node/Pro.""" - - def __init__(self, airvisual): - """Initialize.""" - super().__init__(airvisual) - - self._attr_icon = "mdi:chemical-weapon" - - @property - def air_quality_index(self): - """Return the Air Quality Index (AQI).""" - if self.coordinator.data["settings"]["is_aqi_usa"]: - return self.coordinator.data["measurements"]["aqi_us"] - return self.coordinator.data["measurements"]["aqi_cn"] - - @property - def available(self): - """Return True if entity is available.""" - return bool(self.coordinator.data) - - @property - def carbon_dioxide(self): - """Return the CO2 (carbon dioxide) level.""" - return self.coordinator.data["measurements"].get("co2") - - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, - "name": self.coordinator.data["settings"]["node_name"], - "manufacturer": "AirVisual", - "model": f'{self.coordinator.data["status"]["model"]}', - "sw_version": ( - f'Version {self.coordinator.data["status"]["system_version"]}' - f'{self.coordinator.data["status"]["app_version"]}' - ), - } - - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["settings"]["node_name"] - return f"{node_name} Node/Pro: Air Quality" - - @property - def particulate_matter_2_5(self): - """Return the particulate matter 2.5 level.""" - return self.coordinator.data["measurements"].get("pm2_5") - - @property - def particulate_matter_10(self): - """Return the particulate matter 10 level.""" - return self.coordinator.data["measurements"].get("pm1_0") - - @property - def particulate_matter_0_1(self): - """Return the particulate matter 0.1 level.""" - return self.coordinator.data["measurements"].get("pm0_1") - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return self.coordinator.data["serial_number"] - - @callback - def update_from_latest_data(self): - """Update the entity from the latest data.""" - self._attrs.update( - { - ATTR_VOC: self.coordinator.data["measurements"].get("voc"), - **{ - ATTR_SENSOR_LIFE.format(pollutant): lifespan - for pollutant, lifespan in self.coordinator.data["status"][ - "sensor_life" - ].items() - }, - } - ) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index ef7873a31b1..971dee161cb 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -1,4 +1,6 @@ """Define a config flow manager for AirVisual.""" +from __future__ import annotations + import asyncio from pyairvisual import CloudAPI, NodeSamba @@ -11,6 +13,7 @@ from pyairvisual.errors import ( import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.const import ( CONF_API_KEY, CONF_IP_ADDRESS, @@ -21,6 +24,7 @@ from homeassistant.const import ( CONF_STATE, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from . import async_get_geography_id @@ -64,13 +68,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._entry_data_for_reauth = None - self._geo_id = None + self._entry_data_for_reauth: dict[str, str] = {} + self._geo_id: str | None = None @property - def geography_coords_schema(self): + def geography_coords_schema(self) -> vol.Schema: """Return the data schema for the cloud API.""" return API_KEY_DATA_SCHEMA.extend( { @@ -83,7 +87,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def _async_finish_geography(self, user_input, integration_type): + async def _async_finish_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Validate a Cloud API key.""" websession = aiohttp_client.async_get_clientsession(self.hass) cloud_api = CloudAPI(user_input[CONF_API_KEY], session=websession) @@ -142,25 +148,29 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: integration_type}, ) - async def _async_init_geography(self, user_input, integration_type): + async def _async_init_geography( + self, user_input: dict[str, str], integration_type: str + ) -> FlowResult: """Handle the initialization of the integration via the cloud API.""" self._geo_id = async_get_geography_id(user_input) await self._async_set_unique_id(self._geo_id) self._abort_if_unique_id_configured() return await self._async_finish_geography(user_input, integration_type) - async def _async_set_unique_id(self, unique_id): + async def _async_set_unique_id(self, unique_id: str) -> None: """Set the unique ID of the config flow and abort if it already exists.""" await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Define the config flow to handle options.""" return AirVisualOptionsFlowHandler(config_entry) - async def async_step_geography_by_coords(self, user_input=None): + async def async_step_geography_by_coords( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on latitude/longitude.""" if not user_input: return self.async_show_form( @@ -171,7 +181,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input, INTEGRATION_TYPE_GEOGRAPHY_COORDS ) - async def async_step_geography_by_name(self, user_input=None): + async def async_step_geography_by_name( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the cloud API based on city/state/country.""" if not user_input: return self.async_show_form( @@ -182,7 +194,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input, INTEGRATION_TYPE_GEOGRAPHY_NAME ) - async def async_step_node_pro(self, user_input=None): + async def async_step_node_pro( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initialization of the integration with a Node/Pro.""" if not user_input: return self.async_show_form(step_id="node_pro", data_schema=NODE_PRO_SCHEMA) @@ -208,13 +222,15 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: """Handle configuration by re-auth.""" self._entry_data_for_reauth = data self._geo_id = async_get_geography_id(data) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle re-auth completion.""" if not user_input: return self.async_show_form( @@ -227,7 +243,9 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): conf, self._entry_data_for_reauth[CONF_INTEGRATION_TYPE] ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form( @@ -244,11 +262,13 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): """Handle an AirVisual options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index b94218f6c13..5d6a221dbbe 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -3,7 +3,7 @@ "name": "AirVisual", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airvisual", - "requirements": ["pyairvisual==5.0.8"], + "requirements": ["pyairvisual==5.0.9"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index c5d6621a329..693742217e5 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,5 +1,8 @@ """Support for AirVisual air quality sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -12,12 +15,16 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, CONF_STATE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import AirVisualEntity from .const import ( @@ -36,12 +43,21 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_REGION = "region" -SENSOR_KIND_LEVEL = "air_pollution_level" +DEVICE_CLASS_POLLUTANT_LABEL = "airvisual__pollutant_label" +DEVICE_CLASS_POLLUTANT_LEVEL = "airvisual__pollutant_level" + SENSOR_KIND_AQI = "air_quality_index" -SENSOR_KIND_POLLUTANT = "main_pollutant" SENSOR_KIND_BATTERY_LEVEL = "battery_level" +SENSOR_KIND_CO2 = "carbon_dioxide" SENSOR_KIND_HUMIDITY = "humidity" +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_PM_0_1 = "particulate_matter_0_1" +SENSOR_KIND_PM_1_0 = "particulate_matter_1_0" +SENSOR_KIND_PM_2_5 = "particulate_matter_2_5" +SENSOR_KIND_POLLUTANT = "main_pollutant" +SENSOR_KIND_SENSOR_LIFE = "sensor_life" SENSOR_KIND_TEMPERATURE = "temperature" +SENSOR_KIND_VOC = "voc" GEOGRAPHY_SENSORS = [ (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), @@ -51,27 +67,74 @@ GEOGRAPHY_SENSORS = [ GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} NODE_PRO_SENSORS = [ - (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, PERCENTAGE), - (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE), - (SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS), + (SENSOR_KIND_AQI, "Air Quality Index", None, "mdi:chart-line", "AQI"), + (SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, None, PERCENTAGE), + ( + SENSOR_KIND_CO2, + "C02", + DEVICE_CLASS_CO2, + None, + CONCENTRATION_PARTS_PER_MILLION, + ), + (SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, None, PERCENTAGE), + ( + SENSOR_KIND_PM_0_1, + "PM 0.1", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_PM_1_0, + "PM 1.0", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_PM_2_5, + "PM 2.5", + None, + "mdi:sprinkler", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ), + ( + SENSOR_KIND_TEMPERATURE, + "Temperature", + DEVICE_CLASS_TEMPERATURE, + None, + TEMP_CELSIUS, + ), + ( + SENSOR_KIND_VOC, + "VOC", + None, + "mdi:sprinkler", + CONCENTRATION_PARTS_PER_MILLION, + ), ] -POLLUTANT_LABELS = { - "co": "Carbon Monoxide", - "n2": "Nitrogen Dioxide", - "o3": "Ozone", - "p1": "PM10", - "p2": "PM2.5", - "s2": "Sulfur Dioxide", -} +STATE_POLLUTANT_LABEL_CO = "co" +STATE_POLLUTANT_LABEL_N2 = "n2" +STATE_POLLUTANT_LABEL_O3 = "o3" +STATE_POLLUTANT_LABEL_P1 = "p1" +STATE_POLLUTANT_LABEL_P2 = "p2" +STATE_POLLUTANT_LABEL_S2 = "s2" + +STATE_POLLUTANT_LEVEL_GOOD = "good" +STATE_POLLUTANT_LEVEL_MODERATE = "moderate" +STATE_POLLUTANT_LEVEL_UNHEALTHY_SENSITIVE = "unhealthy_sensitive" +STATE_POLLUTANT_LEVEL_UNHEALTHY = "unhealthy" +STATE_POLLUTANT_LEVEL_VERY_UNHEALTHY = "very_unhealthy" +STATE_POLLUTANT_LEVEL_HAZARDOUS = "hazardous" POLLUTANT_LEVELS = { - (0, 50): ("Good", "mdi:emoticon-excited"), - (51, 100): ("Moderate", "mdi:emoticon-happy"), - (101, 150): ("Unhealthy for sensitive groups", "mdi:emoticon-neutral"), - (151, 200): ("Unhealthy", "mdi:emoticon-sad"), - (201, 300): ("Very unhealthy", "mdi:emoticon-dead"), - (301, 1000): ("Hazardous", "mdi:biohazard"), + (0, 50): (STATE_POLLUTANT_LEVEL_GOOD, "mdi:emoticon-excited"), + (51, 100): (STATE_POLLUTANT_LEVEL_MODERATE, "mdi:emoticon-happy"), + (101, 150): (STATE_POLLUTANT_LEVEL_UNHEALTHY_SENSITIVE, "mdi:emoticon-neutral"), + (151, 200): (STATE_POLLUTANT_LEVEL_UNHEALTHY, "mdi:emoticon-sad"), + (201, 300): (STATE_POLLUTANT_LEVEL_VERY_UNHEALTHY, "mdi:emoticon-dead"), + (301, 1000): (STATE_POLLUTANT_LEVEL_HAZARDOUS, "mdi:biohazard"), } POLLUTANT_UNITS = { @@ -84,10 +147,15 @@ POLLUTANT_UNITS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up AirVisual sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + sensors: list[AirVisualGeographySensor | AirVisualNodeProSensor] if config_entry.data[CONF_INTEGRATION_TYPE] in [ INTEGRATION_TYPE_GEOGRAPHY_COORDS, INTEGRATION_TYPE_GEOGRAPHY_NAME, @@ -107,8 +175,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ] else: sensors = [ - AirVisualNodeProSensor(coordinator, kind, name, device_class, unit) - for kind, name, device_class, unit in NODE_PRO_SENSORS + AirVisualNodeProSensor(coordinator, kind, name, device_class, icon, unit) + for kind, name, device_class, icon, unit in NODE_PRO_SENSORS ] async_add_entities(sensors, True) @@ -117,53 +185,45 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AirVisualGeographySensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to geography data via the Cloud API.""" - def __init__(self, coordinator, config_entry, kind, name, icon, unit, locale): + def __init__( + self, + coordinator: DataUpdateCoordinator, + config_entry: ConfigEntry, + kind: str, + name: str, + icon: str, + unit: str | None, + locale: str, + ) -> None: """Initialize.""" super().__init__(coordinator) - self._attrs.update( + if kind == SENSOR_KIND_LEVEL: + self._attr_device_class = DEVICE_CLASS_POLLUTANT_LEVEL + elif kind == SENSOR_KIND_POLLUTANT: + self._attr_device_class = DEVICE_CLASS_POLLUTANT_LABEL + self._attr_extra_state_attributes.update( { ATTR_CITY: config_entry.data.get(CONF_CITY), ATTR_STATE: config_entry.data.get(CONF_STATE), ATTR_COUNTRY: config_entry.data.get(CONF_COUNTRY), } ) + self._attr_icon = icon + self._attr_name = f"{GEOGRAPHY_SENSOR_LOCALES[locale]} {name}" + self._attr_unique_id = f"{config_entry.unique_id}_{locale}_{kind}" + self._attr_unit_of_measurement = unit self._config_entry = config_entry self._kind = kind self._locale = locale - self._name = name - self._state = None - - self._attr_icon = icon - self._attr_unit_of_measurement = unit @property - def available(self): - """Return True if entity is available.""" - try: - return self.coordinator.last_update_success and bool( - self.coordinator.data["current"]["pollution"] - ) - except KeyError: - return False - - @property - def name(self): - """Return the name.""" - return f"{GEOGRAPHY_SENSOR_LOCALES[self._locale]} {self._name}" - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._config_entry.unique_id}_{self._locale}_{self._kind}" + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data["current"]["pollution"] @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" try: data = self.coordinator.data["current"]["pollution"] @@ -172,17 +232,17 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): if self._kind == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] - [(self._state, self._attr_icon)] = [ + [(self._attr_state, self._attr_icon)] = [ (name, icon) for (floor, ceiling), (name, icon) in POLLUTANT_LEVELS.items() if floor <= aqi <= ceiling ] elif self._kind == SENSOR_KIND_AQI: - self._state = data[f"aqi{self._locale}"] + self._attr_state = data[f"aqi{self._locale}"] elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] - self._state = POLLUTANT_LABELS[symbol] - self._attrs.update( + self._attr_state = symbol + self._attr_extra_state_attributes.update( { ATTR_POLLUTANT_SYMBOL: symbol, ATTR_POLLUTANT_UNIT: POLLUTANT_UNITS[symbol], @@ -206,33 +266,43 @@ class AirVisualGeographySensor(AirVisualEntity, SensorEntity): ) if self._config_entry.options[CONF_SHOW_ON_MAP]: - self._attrs[ATTR_LATITUDE] = latitude - self._attrs[ATTR_LONGITUDE] = longitude - self._attrs.pop("lati", None) - self._attrs.pop("long", None) + self._attr_extra_state_attributes[ATTR_LATITUDE] = latitude + self._attr_extra_state_attributes[ATTR_LONGITUDE] = longitude + self._attr_extra_state_attributes.pop("lati", None) + self._attr_extra_state_attributes.pop("long", None) else: - self._attrs["lati"] = latitude - self._attrs["long"] = longitude - self._attrs.pop(ATTR_LATITUDE, None) - self._attrs.pop(ATTR_LONGITUDE, None) + self._attr_extra_state_attributes["lati"] = latitude + self._attr_extra_state_attributes["long"] = longitude + self._attr_extra_state_attributes.pop(ATTR_LATITUDE, None) + self._attr_extra_state_attributes.pop(ATTR_LONGITUDE, None) class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): """Define an AirVisual sensor related to a Node/Pro unit.""" - def __init__(self, coordinator, kind, name, device_class, unit): + def __init__( + self, + coordinator: DataUpdateCoordinator, + kind: str, + name: str, + device_class: str | None, + icon: str | None, + unit: str, + ) -> None: """Initialize.""" super().__init__(coordinator) - self._kind = kind - self._name = name - self._state = None - self._attr_device_class = device_class + self._attr_icon = icon + self._attr_name = ( + f"{coordinator.data['settings']['node_name']} Node/Pro: {name}" + ) + self._attr_unique_id = f"{coordinator.data['serial_number']}_{kind}" self._attr_unit_of_measurement = unit + self._kind = kind @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device registry information for this entity.""" return { "identifiers": {(DOMAIN, self.coordinator.data["serial_number"])}, @@ -245,28 +315,29 @@ class AirVisualNodeProSensor(AirVisualEntity, SensorEntity): ), } - @property - def name(self): - """Return the name.""" - node_name = self.coordinator.data["settings"]["node_name"] - return f"{node_name} Node/Pro: {self._name}" - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.coordinator.data['serial_number']}_{self._kind}" - @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - if self._kind == SENSOR_KIND_BATTERY_LEVEL: - self._state = self.coordinator.data["status"]["battery"] + if self._kind == SENSOR_KIND_AQI: + if self.coordinator.data["settings"]["is_aqi_usa"]: + self._attr_state = self.coordinator.data["measurements"]["aqi_us"] + else: + self._attr_state = self.coordinator.data["measurements"]["aqi_cn"] + elif self._kind == SENSOR_KIND_BATTERY_LEVEL: + self._attr_state = self.coordinator.data["status"]["battery"] + elif self._kind == SENSOR_KIND_CO2: + self._attr_state = self.coordinator.data["measurements"].get("co2") elif self._kind == SENSOR_KIND_HUMIDITY: - self._state = self.coordinator.data["measurements"].get("humidity") + self._attr_state = self.coordinator.data["measurements"].get("humidity") + elif self._kind == SENSOR_KIND_PM_0_1: + self._attr_state = self.coordinator.data["measurements"].get("pm0_1") + elif self._kind == SENSOR_KIND_PM_1_0: + self._attr_state = self.coordinator.data["measurements"].get("pm1_0") + elif self._kind == SENSOR_KIND_PM_2_5: + self._attr_state = self.coordinator.data["measurements"].get("pm2_5") elif self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinator.data["measurements"].get("temperature_C") + self._attr_state = self.coordinator.data["measurements"].get( + "temperature_C" + ) + elif self._kind == SENSOR_KIND_VOC: + self._attr_state = self.coordinator.data["measurements"].get("voc") diff --git a/homeassistant/components/airvisual/strings.sensor.json b/homeassistant/components/airvisual/strings.sensor.json new file mode 100644 index 00000000000..583ddaf4f3b --- /dev/null +++ b/homeassistant/components/airvisual/strings.sensor.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Carbon Monoxide", + "n2": "Nitrogen Dioxide", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioxide" + }, + "airvisual__pollutant_level": { + "good": "Good", + "moderate": "Moderate", + "unhealthy": "Unhealthy", + "unhealthy_sensitive": "Unhealthy for sensitive groups", + "very_unhealthy": "Very unhealthy", + "hazardous": "Hazardous" + } + } +} diff --git a/homeassistant/components/airvisual/translations/de.json b/homeassistant/components/airvisual/translations/de.json index 588d69f96fe..c6d00ea1375 100644 --- a/homeassistant/components/airvisual/translations/de.json +++ b/homeassistant/components/airvisual/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Koordinaten oder Node/Pro ID sind bereits registriert.", + "already_configured": "Diese Node/Pro ID oder Standort ist bereits konfiguriert.", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { @@ -35,18 +35,18 @@ "ip_address": "Host", "password": "Passwort" }, - "description": "\u00dcberwachen Sie eine pers\u00f6nliche AirVisual-Einheit. Das Passwort kann von der Benutzeroberfl\u00e4che des Ger\u00e4ts abgerufen werden.", - "title": "Konfigurieren Sie einen AirVisual Node/Pro" + "description": "\u00dcberwache eine pers\u00f6nliche AirVisual-Einheit. Das Passwort kann von der Benutzeroberfl\u00e4che des Ger\u00e4ts abgerufen werden.", + "title": "Konfiguriere einen AirVisual Node/Pro" }, "reauth_confirm": { "data": { - "api_key": "API-Key" + "api_key": "API-Schl\u00fcssel" }, "title": "AirVisual erneut authentifizieren" }, "user": { - "description": "W\u00e4hlen Sie aus, welche Art von AirVisual-Daten Sie \u00fcberwachen m\u00f6chten.", - "title": "Konfigurieren Sie AirVisual" + "description": "W\u00e4hle aus, welche Art von AirVisual-Daten du \u00fcberwachen m\u00f6chtest.", + "title": "Konfiguriere AirVisual" } } }, @@ -54,9 +54,9 @@ "step": { "init": { "data": { - "show_on_map": "Zeigen Sie die \u00fcberwachte Geografie auf der Karte an" + "show_on_map": "Zeige die \u00fcberwachte Geografie auf der Karte an" }, - "title": "Konfigurieren Sie AirVisual" + "title": "Konfiguriere AirVisual" } } } diff --git a/homeassistant/components/airvisual/translations/he.json b/homeassistant/components/airvisual/translations/he.json index 5dfc5cbdd73..6d5684220aa 100644 --- a/homeassistant/components/airvisual/translations/he.json +++ b/homeassistant/components/airvisual/translations/he.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "general_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", - "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9 \u05e1\u05d5\u05e4\u05e7", + "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "location_not_found": "\u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05dc\u05d0 \u05e0\u05de\u05e6\u05d0" }, "step": { @@ -35,5 +35,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "\u05d4\u05e6\u05d2 \u05d2\u05d9\u05d0\u05d5\u05d2\u05e8\u05e4\u05d9\u05d4 \u05de\u05e0\u05d5\u05d8\u05e8\u05ea \u05d1\u05de\u05e4\u05d4" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/hu.json b/homeassistant/components/airvisual/translations/hu.json index 704ce33ab67..e7c47e93793 100644 --- a/homeassistant/components/airvisual/translations/hu.json +++ b/homeassistant/components/airvisual/translations/hu.json @@ -7,7 +7,8 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "general_error": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs" + "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "location_not_found": "A hely nem tal\u00e1lhat\u00f3" }, "step": { "geography_by_coords": { @@ -15,14 +16,19 @@ "api_key": "API kulcs", "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" - } + }, + "description": "Haszn\u00e1lja az AirVisual felh\u0151 API-t a sz\u00e9less\u00e9g / hossz\u00fas\u00e1g figyel\u00e9s\u00e9hez.", + "title": "Konfigur\u00e1lja a geogr\u00e1fi\u00e1t" }, "geography_by_name": { "data": { "api_key": "API kulcs", "city": "V\u00e1ros", - "country": "Orsz\u00e1g" - } + "country": "Orsz\u00e1g", + "state": "\u00e1llapot" + }, + "description": "Haszn\u00e1lja az AirVisual felh\u0151 API-t egy v\u00e1ros / \u00e1llam / orsz\u00e1g figyel\u00e9s\u00e9hez.", + "title": "Konfigur\u00e1lja a geogr\u00e1fi\u00e1t" }, "node_pro": { "data": { @@ -33,7 +39,8 @@ "reauth_confirm": { "data": { "api_key": "API kulcs" - } + }, + "title": "Az AirVisual \u00fajb\u00f3li hiteles\u00edt\u00e9se" } } } diff --git a/homeassistant/components/airvisual/translations/ru.json b/homeassistant/components/airvisual/translations/ru.json index 4f0073d3132..f774ec76aaf 100644 --- a/homeassistant/components/airvisual/translations/ru.json +++ b/homeassistant/components/airvisual/translations/ru.json @@ -17,7 +17,7 @@ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0448\u0438\u0440\u043e\u0442\u044b/\u0434\u043e\u043b\u0433\u043e\u0442\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u043f\u043e \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0430\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "geography_by_name": { @@ -25,9 +25,9 @@ "api_key": "\u041a\u043b\u044e\u0447 API", "city": "\u0413\u043e\u0440\u043e\u0434", "country": "\u0421\u0442\u0440\u0430\u043d\u0430", - "state": "\u0448\u0442\u0430\u0442" + "state": "\u0428\u0442\u0430\u0442" }, - "description": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b.", + "description": "\u0414\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0433\u043e\u0440\u043e\u0434\u0430/\u0448\u0442\u0430\u0442\u0430/\u0441\u0442\u0440\u0430\u043d\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043e\u0431\u043b\u0430\u0447\u043d\u044b\u0439 API AirVisual.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f" }, "node_pro": { diff --git a/homeassistant/components/airvisual/translations/sensor.ca.json b/homeassistant/components/airvisual/translations/sensor.ca.json new file mode 100644 index 00000000000..236dca64d4e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ca.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Mon\u00f2xid de carboni", + "n2": "Di\u00f2xid de nitrogen", + "o3": "Oz\u00f3", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Di\u00f2xid de sofre" + }, + "airvisual__pollutant_level": { + "good": "Bo", + "hazardous": "Perill\u00f3s", + "moderate": "Moderat", + "unhealthy": "Poc saludable", + "unhealthy_sensitive": "Poc saludable per a grups sensibles", + "very_unhealthy": "Molt poc saludable" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.de.json b/homeassistant/components/airvisual/translations/sensor.de.json new file mode 100644 index 00000000000..d6aeab515bd --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.de.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Kohlenmonoxid", + "n2": "Stickstoffdioxid", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2,5", + "s2": "Schwefeldioxid" + }, + "airvisual__pollutant_level": { + "good": "Gut", + "hazardous": "Gef\u00e4hrlich", + "moderate": "M\u00e4\u00dfig", + "unhealthy": "Ungesund", + "unhealthy_sensitive": "Ungesund f\u00fcr sensible Gruppen", + "very_unhealthy": "Sehr ungesund" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.en.json b/homeassistant/components/airvisual/translations/sensor.en.json new file mode 100644 index 00000000000..314cf34562a --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.en.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Carbon Monoxide", + "n2": "Nitrogen Dioxide", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Sulfur Dioxide" + }, + "airvisual__pollutant_level": { + "good": "Good", + "hazardous": "Hazardous", + "moderate": "Moderate", + "unhealthy": "Unhealthy", + "unhealthy_sensitive": "Unhealthy for sensitive groups", + "very_unhealthy": "Very unhealthy" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.et.json b/homeassistant/components/airvisual/translations/sensor.et.json new file mode 100644 index 00000000000..14f3d82c11d --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.et.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Vingugaas", + "n2": "L\u00e4mmastikdioksiid", + "o3": "Osoon", + "p1": "PM10 osakesed", + "p2": "PM2.5 osakesed", + "s2": "V\u00e4\u00e4veldioksiid" + }, + "airvisual__pollutant_level": { + "good": "Hea", + "hazardous": "Ohtlik", + "moderate": "M\u00f5\u00f5dukas", + "unhealthy": "Ebatervislik", + "unhealthy_sensitive": "Ebatervislik riskir\u00fchmale", + "very_unhealthy": "V\u00e4ga ebatervislik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.fr.json b/homeassistant/components/airvisual/translations/sensor.fr.json new file mode 100644 index 00000000000..3050d6fb158 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.fr.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Monoxyde de carbone", + "n2": "Dioxyde d'azote", + "o3": "Ozone", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Dioxyde de soufre" + }, + "airvisual__pollutant_level": { + "good": "Bon", + "hazardous": "Hasardeux", + "moderate": "Mod\u00e9rer", + "unhealthy": "Malsain", + "unhealthy_sensitive": "Malsain pour les groupes sensibles", + "very_unhealthy": "Tr\u00e8s malsain" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json new file mode 100644 index 00000000000..28ac8c5c3e4 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -0,0 +1,8 @@ +{ + "state": { + "airvisual__pollutant_level": { + "good": "\u05d8\u05d5\u05d1", + "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.it.json b/homeassistant/components/airvisual/translations/sensor.it.json new file mode 100644 index 00000000000..7fb8b98215c --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.it.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Monossido di carbonio", + "n2": "Anidride nitrosa", + "o3": "Ozono", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Anidride solforosa" + }, + "airvisual__pollutant_level": { + "good": "Buono", + "hazardous": "Pericoloso", + "moderate": "Moderato", + "unhealthy": "Malsano", + "unhealthy_sensitive": "Malsano per gruppi sensibili", + "very_unhealthy": "Molto malsano" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.nl.json b/homeassistant/components/airvisual/translations/sensor.nl.json new file mode 100644 index 00000000000..72f07853e49 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.nl.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Koolmonoxide", + "n2": "Stikstofdioxide", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Zwaveldioxide" + }, + "airvisual__pollutant_level": { + "good": "Goed", + "hazardous": "Gevaarlijk", + "moderate": "Matig", + "unhealthy": "Ongezond", + "unhealthy_sensitive": "Ongezond voor gevoelige groepen", + "very_unhealthy": "Heel ongezond" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.no.json b/homeassistant/components/airvisual/translations/sensor.no.json new file mode 100644 index 00000000000..86c95f8e8f2 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "airvisual__pollutant_label": { + "p1": "PM10", + "p2": "PM2.5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.pl.json b/homeassistant/components/airvisual/translations/sensor.pl.json new file mode 100644 index 00000000000..48835f36f69 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.pl.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Tlenek w\u0119gla", + "n2": "Dwutlenek azotu", + "o3": "Ozon", + "p1": "PM10", + "p2": "PM2.5", + "s2": "Dwutlenek siarki" + }, + "airvisual__pollutant_level": { + "good": "dobry", + "hazardous": "niebezpieczny", + "moderate": "umiarkowany", + "unhealthy": "niezdrowy", + "unhealthy_sensitive": "niezdrowy dla grup wra\u017cliwych", + "very_unhealthy": "bardzo niezdrowy" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.ru.json b/homeassistant/components/airvisual/translations/sensor.ru.json new file mode 100644 index 00000000000..d75bcc4ee9e --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.ru.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u0423\u0433\u0430\u0440\u043d\u044b\u0439 \u0433\u0430\u0437", + "n2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0430\u0437\u043e\u0442\u0430", + "o3": "\u041e\u0437\u043e\u043d", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u0414\u0438\u043e\u043a\u0441\u0438\u0434 \u0441\u0435\u0440\u044b" + }, + "airvisual__pollutant_level": { + "good": "\u0425\u043e\u0440\u043e\u0448\u043e", + "hazardous": "\u041e\u043f\u0430\u0441\u043d\u043e", + "moderate": "\u0421\u0440\u0435\u0434\u043d\u0435", + "unhealthy": "\u0412\u0440\u0435\u0434\u043d\u043e", + "unhealthy_sensitive": "\u0412\u0440\u0435\u0434\u043d\u043e \u0434\u043b\u044f \u0443\u044f\u0437\u0432\u0438\u043c\u044b\u0445 \u0433\u0440\u0443\u043f\u043f", + "very_unhealthy": "\u041e\u0447\u0435\u043d\u044c \u0432\u0440\u0435\u0434\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sensor.zh-Hant.json b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..cedd3e33ae6 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.zh-Hant.json @@ -0,0 +1,20 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "\u4e00\u6c27\u5316\u78b3", + "n2": "\u4e8c\u6c27\u5316\u6c2e", + "o3": "\u81ed\u6c27", + "p1": "PM10", + "p2": "PM2.5", + "s2": "\u4e8c\u6c27\u5316\u786b" + }, + "airvisual__pollutant_level": { + "good": "\u826f\u597d", + "hazardous": "\u5371\u96aa", + "moderate": "\u4e2d\u7b49", + "unhealthy": "\u4e0d\u5065\u5eb7", + "unhealthy_sensitive": "\u5c0d\u654f\u611f\u65cf\u7fa4\u4e0d\u5065\u5eb7", + "very_unhealthy": "\u975e\u5e38\u4e0d\u5065\u5eb7" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index d4ae9cbb2fd..85f89f3043b 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -8,6 +8,7 @@ from aladdin_connect import AladdinConnectClient import voluptuous as vol from homeassistant.components.cover import ( + DEVICE_CLASS_GARAGE, PLATFORM_SCHEMA as BASE_PLATFORM_SCHEMA, CoverEntity, ) @@ -61,50 +62,16 @@ def setup_platform( class AladdinDevice(CoverEntity): """Representation of Aladdin Connect cover.""" + _attr_device_class = DEVICE_CLASS_GARAGE + _attr_supported_features = SUPPORTED_FEATURES + def __init__(self, acc: AladdinConnectClient, device: DoorDevice) -> None: """Initialize the cover.""" self._acc = acc self._device_id = device["device_id"] self._number = device["door_number"] - self._name = device["name"] - self._status = STATES_MAP.get(device["status"]) - - @property - def device_class(self) -> str: - """Define this cover as a garage door.""" - return "garage" - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORTED_FEATURES - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device_id}-{self._number}" - - @property - def name(self) -> str: - """Return the name of the garage door.""" - return self._name - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._status == STATE_OPENING - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._status == STATE_CLOSING - - @property - def is_closed(self) -> bool | None: - """Return None if status is unknown, True if closed, else False.""" - if self._status is None: - return None - return self._status == STATE_CLOSED + self._attr_name = device["name"] + self._attr_unique_id = f"{self._device_id}-{self._number}" def close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" @@ -116,5 +83,9 @@ class AladdinDevice(CoverEntity): def update(self) -> None: """Update status of cover.""" - acc_status = self._acc.get_door_status(self._device_id, self._number) - self._status = STATES_MAP.get(acc_status) + status = STATES_MAP.get( + self._acc.get_door_status(self._device_id, self._number) + ) + self._attr_is_opening = status == STATE_OPENING + self._attr_is_closing = status == STATE_CLOSING + self._attr_is_closed = None if status is None else status == STATE_CLOSED diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index c8da648fec6..50317e97f2b 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,6 +1,7 @@ """Component to interface with an alarm control panel.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, Final, final @@ -15,13 +16,14 @@ from homeassistant.const import ( 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 import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import make_entity_service_schema -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -30,6 +32,7 @@ from .const import ( SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) @@ -81,6 +84,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_alarm_arm_night", [SUPPORT_ALARM_ARM_NIGHT], ) + component.async_register_entity_service( + SERVICE_ALARM_ARM_VACATION, + ALARM_SERVICE_SCHEMA, + "async_alarm_arm_vacation", + [SUPPORT_ALARM_ARM_VACATION], + ) component.async_register_entity_service( SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA, @@ -109,9 +118,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class AlarmControlPanelEntityDescription(EntityDescription): + """A class that describes alarm control panel entities.""" + + class AlarmControlPanelEntity(Entity): """An abstract class for alarm control entities.""" + entity_description: AlarmControlPanelEntityDescription _attr_changed_by: str | None = None _attr_code_arm_required: bool = True _attr_code_format: str | None = None @@ -164,6 +179,14 @@ class AlarmControlPanelEntity(Entity): """Send arm night command.""" await self.hass.async_add_executor_job(self.alarm_arm_night, code) + def alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + raise NotImplementedError() + + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: + """Send arm vacation command.""" + await self.hass.async_add_executor_job(self.alarm_arm_vacation, code) + def alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" raise NotImplementedError() diff --git a/homeassistant/components/alarm_control_panel/const.py b/homeassistant/components/alarm_control_panel/const.py index 36e3b6a13eb..f3688a27958 100644 --- a/homeassistant/components/alarm_control_panel/const.py +++ b/homeassistant/components/alarm_control_panel/const.py @@ -7,10 +7,12 @@ SUPPORT_ALARM_ARM_AWAY: Final = 2 SUPPORT_ALARM_ARM_NIGHT: Final = 4 SUPPORT_ALARM_TRIGGER: Final = 8 SUPPORT_ALARM_ARM_CUSTOM_BYPASS: Final = 16 +SUPPORT_ALARM_ARM_VACATION: Final = 32 CONDITION_TRIGGERED: Final = "is_triggered" CONDITION_DISARMED: Final = "is_disarmed" CONDITION_ARMED_HOME: Final = "is_armed_home" CONDITION_ARMED_AWAY: Final = "is_armed_away" CONDITION_ARMED_NIGHT: Final = "is_armed_night" +CONDITION_ARMED_VACATION: Final = "is_armed_vacation" CONDITION_ARMED_CUSTOM_BYPASS: Final = "is_armed_custom_bypass" diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index d92f9615c9a..c37bddafcd3 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -16,6 +16,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, ) @@ -30,6 +31,7 @@ from .const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) @@ -37,6 +39,7 @@ ACTION_TYPES: Final[set[str]] = { "arm_away", "arm_home", "arm_night", + "arm_vacation", "disarm", "trigger", } @@ -77,6 +80,8 @@ async def async_get_actions( actions.append({**base_action, CONF_TYPE: "arm_home"}) if supported_features & SUPPORT_ALARM_ARM_NIGHT: actions.append({**base_action, CONF_TYPE: "arm_night"}) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + actions.append({**base_action, CONF_TYPE: "arm_vacation"}) actions.append({**base_action, CONF_TYPE: "disarm"}) if supported_features & SUPPORT_ALARM_TRIGGER: actions.append({**base_action, CONF_TYPE: "trigger"}) @@ -98,6 +103,8 @@ async def async_call_action_from_config( service = SERVICE_ALARM_ARM_HOME elif config[CONF_TYPE] == "arm_night": service = SERVICE_ALARM_ARM_NIGHT + elif config[CONF_TYPE] == "arm_vacation": + service = SERVICE_ALARM_ARM_VACATION elif config[CONF_TYPE] == "disarm": service = SERVICE_ALARM_DISARM elif config[CONF_TYPE] == "trigger": diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 3cbaa019ad0..9367ef8f811 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -10,6 +10,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_CUSTOM_BYPASS, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -22,6 +23,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -37,6 +39,7 @@ from .const import ( CONDITION_ARMED_CUSTOM_BYPASS, CONDITION_ARMED_HOME, CONDITION_ARMED_NIGHT, + CONDITION_ARMED_VACATION, CONDITION_DISARMED, CONDITION_TRIGGERED, ) @@ -47,6 +50,7 @@ CONDITION_TYPES: Final[set[str]] = { CONDITION_ARMED_HOME, CONDITION_ARMED_AWAY, CONDITION_ARMED_NIGHT, + CONDITION_ARMED_VACATION, CONDITION_ARMED_CUSTOM_BYPASS, } @@ -90,6 +94,8 @@ async def async_get_conditions( conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_AWAY}) if supported_features & SUPPORT_ALARM_ARM_NIGHT: conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_NIGHT}) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + conditions.append({**base_condition, CONF_TYPE: CONDITION_ARMED_VACATION}) if supported_features & SUPPORT_ALARM_ARM_CUSTOM_BYPASS: conditions.append( {**base_condition, CONF_TYPE: CONDITION_ARMED_CUSTOM_BYPASS} @@ -114,6 +120,8 @@ def async_condition_from_config( state = STATE_ALARM_ARMED_AWAY elif config[CONF_TYPE] == CONDITION_ARMED_NIGHT: state = STATE_ALARM_ARMED_NIGHT + elif config[CONF_TYPE] == CONDITION_ARMED_VACATION: + state = STATE_ALARM_ARMED_VACATION elif config[CONF_TYPE] == CONDITION_ARMED_CUSTOM_BYPASS: state = STATE_ALARM_ARMED_CUSTOM_BYPASS diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index f89e03e7326..9ab6e466b6c 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, ) from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA @@ -23,6 +24,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, @@ -39,6 +41,7 @@ TRIGGER_TYPES: Final[set[str]] = BASIC_TRIGGER_TYPES | { "armed_home", "armed_away", "armed_night", + "armed_vacation", } TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( @@ -100,6 +103,13 @@ async def async_get_triggers( CONF_TYPE: "armed_night", } ) + if supported_features & SUPPORT_ALARM_ARM_VACATION: + triggers.append( + { + **base_trigger, + CONF_TYPE: "armed_vacation", + } + ) return triggers @@ -134,6 +144,8 @@ async def async_attach_trigger( to_state = STATE_ALARM_ARMED_AWAY elif config[CONF_TYPE] == "armed_night": to_state = STATE_ALARM_ARMED_NIGHT + elif config[CONF_TYPE] == "armed_vacation": + to_state = STATE_ALARM_ARMED_VACATION state_config = { state_trigger.CONF_PLATFORM: "state", diff --git a/homeassistant/components/alarm_control_panel/group.py b/homeassistant/components/alarm_control_panel/group.py index 4bfb1486814..dabe49069d5 100644 --- a/homeassistant/components/alarm_control_panel/group.py +++ b/homeassistant/components/alarm_control_panel/group.py @@ -7,6 +7,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, STATE_OFF, ) @@ -24,6 +25,7 @@ def async_describe_on_off_states( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, }, STATE_OFF, diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py index 019ba35c013..3fcc540d04b 100644 --- a/homeassistant/components/alarm_control_panel/reproduce_state.py +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -12,12 +12,14 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -32,6 +34,7 @@ VALID_STATES: Final[set[str]] = { STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, } @@ -71,6 +74,8 @@ async def _async_reproduce_state( service = SERVICE_ALARM_ARM_HOME elif state.state == STATE_ALARM_ARMED_NIGHT: service = SERVICE_ALARM_ARM_NIGHT + elif state.state == STATE_ALARM_ARMED_VACATION: + service = SERVICE_ALARM_ARM_VACATION elif state.state == STATE_ALARM_DISARMED: service = SERVICE_ALARM_DISARM elif state.state == STATE_ALARM_TRIGGERED: diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml index 8c148a6a1e0..0bf3952c4ed 100644 --- a/homeassistant/components/alarm_control_panel/services.yaml +++ b/homeassistant/components/alarm_control_panel/services.yaml @@ -70,6 +70,20 @@ alarm_arm_night: selector: text: +alarm_arm_vacation: + name: Arm vacation + description: Send the alarm the command for arm vacation. + target: + entity: + domain: alarm_control_panel + fields: + code: + name: Code + description: An optional code to arm vacation the alarm control panel with. + example: "1234" + selector: + text: + alarm_trigger: name: Trigger description: Send the alarm the command for trigger. diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index de89d28082b..5126f49d92b 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -5,6 +5,7 @@ "arm_away": "Arm {entity_name} away", "arm_home": "Arm {entity_name} home", "arm_night": "Arm {entity_name} night", + "arm_vacation": "Arm {entity_name} vacation", "disarm": "Disarm {entity_name}", "trigger": "Trigger {entity_name}" }, @@ -13,14 +14,16 @@ "is_disarmed": "{entity_name} is disarmed", "is_armed_home": "{entity_name} is armed home", "is_armed_away": "{entity_name} is armed away", - "is_armed_night": "{entity_name} is armed night" + "is_armed_night": "{entity_name} is armed night", + "is_armed_vacation": "{entity_name} is armed vacation" }, "trigger_type": { "triggered": "{entity_name} triggered", "disarmed": "{entity_name} disarmed", "armed_home": "{entity_name} armed home", "armed_away": "{entity_name} armed away", - "armed_night": "{entity_name} armed night" + "armed_night": "{entity_name} armed night", + "armed_vacation": "{entity_name} armed vacation" } }, "state": { @@ -30,6 +33,7 @@ "armed_home": "Armed home", "armed_away": "Armed away", "armed_night": "Armed night", + "armed_vacation": "Armed vacation", "armed_custom_bypass": "Armed custom bypass", "pending": "Pending", "arming": "Arming", diff --git a/homeassistant/components/alarm_control_panel/translations/ar.json b/homeassistant/components/alarm_control_panel/translations/ar.json index 427b30eebbe..66857391c28 100644 --- a/homeassistant/components/alarm_control_panel/translations/ar.json +++ b/homeassistant/components/alarm_control_panel/translations/ar.json @@ -1,11 +1,29 @@ { + "device_automation": { + "action_type": { + "disarm": "\u0627\u0644\u063a\u064a \u062a\u0641\u0639\u064a\u0644 {entity_name}", + "trigger": "\u062a\u0634\u063a\u064a\u0644 {entity_name}" + }, + "condition_type": { + "is_armed_away": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0639\u064a\u062f\u0627", + "is_armed_home": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0627\u0644\u0645\u0646\u0632\u0644", + "is_disarmed": "{entity_name} \u0627\u0644\u063a\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644", + "is_triggered": "\u062a\u0645 \u062a\u0634\u063a\u064a\u0644 {entity_name}" + }, + "trigger_type": { + "armed_away": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0639\u064a\u062f\u0627", + "armed_home": "{entity_name} \u0645\u0641\u0639\u0644 \u0628\u0627\u0644\u0645\u0646\u0632\u0644", + "disarmed": "{entity_name} \u0627\u0644\u063a\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644" + } + }, "state": { "_": { - "armed": "\u0645\u0633\u0644\u062d", + "armed": "\u0645\u0641\u0639\u0651\u0644", "armed_away": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u062e\u0627\u0631\u062c", "armed_custom_bypass": "\u062a\u062c\u0627\u0648\u0632 \u0627\u0644\u062a\u0641\u0639\u064a\u0644", "armed_home": "\u0645\u0641\u0639\u0651\u0644 \u0641\u064a \u0627\u0644\u0645\u0646\u0632\u0644", "armed_night": "\u0645\u0641\u0639\u0651\u0644 \u0644\u064a\u0644", + "armed_vacation": "\u0645\u0641\u0639\u0644 \u0628\u0648\u0636\u0639 \u0627\u0644\u0627\u062c\u0627\u0632\u0629", "arming": "\u062c\u0627\u0631\u064a \u0627\u0644\u062a\u0641\u0639\u064a\u0644", "disarmed": "\u063a\u064a\u0631 \u0645\u0641\u0639\u0651\u0644", "disarming": "\u0625\u064a\u0642\u0627\u0641 \u0627\u0644\u0625\u0646\u0630\u0627\u0631", diff --git a/homeassistant/components/alarm_control_panel/translations/ca.json b/homeassistant/components/alarm_control_panel/translations/ca.json index dafef96b090..d576b5b629a 100644 --- a/homeassistant/components/alarm_control_panel/translations/ca.json +++ b/homeassistant/components/alarm_control_panel/translations/ca.json @@ -4,6 +4,7 @@ "arm_away": "Activa {entity_name} fora", "arm_home": "Activa {entity_name} a casa", "arm_night": "Activa {entity_name} nocturn", + "arm_vacation": "Activa {entity_name} en mode vacances", "disarm": "Desactiva {entity_name}", "trigger": "Dispara {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} est\u00e0 activada en mode 'a fora'", "is_armed_home": "{entity_name} est\u00e0 activada en mode 'a casa'", "is_armed_night": "{entity_name} est\u00e0 activada en mode 'nocturn'", + "is_armed_vacation": "{entity_name} activada en mode vacances", "is_disarmed": "{entity_name} est\u00e0 desactivada", "is_triggered": "{entity_name} est\u00e0 disparada" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} activada en mode 'a fora'", "armed_home": "{entity_name} activada en mode 'a casa'", "armed_night": "{entity_name} activada en mode 'nocturn'", + "armed_vacation": "{entity_name} s'activa en mode vacances", "disarmed": "{entity_name} desactivada", "triggered": "{entity_name} disparat/ada" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Activada, bypass personalitzat", "armed_home": "Activada, mode a casa", "armed_night": "Activada, mode nocturn", + "armed_vacation": "Activada, mode vacances", "arming": "Activant", "disarmed": "Desactivada", "disarming": "Desactivant", diff --git a/homeassistant/components/alarm_control_panel/translations/de.json b/homeassistant/components/alarm_control_panel/translations/de.json index a671c388932..379ddfc041d 100644 --- a/homeassistant/components/alarm_control_panel/translations/de.json +++ b/homeassistant/components/alarm_control_panel/translations/de.json @@ -4,6 +4,7 @@ "arm_away": "Aktiviere {entity_name} Unterwegs", "arm_home": "Aktiviere {entity_name} Zuhause", "arm_night": "Aktiviere {entity_name} Nacht-Modus", + "arm_vacation": "Aktiviere {entity_name} Urlaub", "disarm": "Deaktivere {entity_name}", "trigger": "Ausl\u00f6ser {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} ist aktiviert - Unterwegs", "is_armed_home": "{entity_name} ist aktiviert - Zuhause", "is_armed_night": "{entity_name} ist aktiviert - Nacht", + "is_armed_vacation": "{entity_name} ist aktiviert - Urlaub", "is_disarmed": "{entity_name} ist deaktiviert", "is_triggered": "{entity_name} wurde ausgel\u00f6st" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} Unterwegs", "armed_home": "{entity_name} Zuhause", "armed_night": "{entity_name} Nacht-Modus", + "armed_vacation": "{entity_name} Urlaub", "disarmed": "{entity_name} deaktiviert", "triggered": "{entity_name} ausgel\u00f6st" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Aktiv, benutzerdefiniert", "armed_home": "Aktiv, zu Hause", "armed_night": "Aktiv, Nacht", + "armed_vacation": "Aktiv, Urlaub", "arming": "Aktiviere", "disarmed": "Inaktiv", "disarming": "Deaktiviere", diff --git a/homeassistant/components/alarm_control_panel/translations/en.json b/homeassistant/components/alarm_control_panel/translations/en.json index b364d850461..c9e9541fc30 100644 --- a/homeassistant/components/alarm_control_panel/translations/en.json +++ b/homeassistant/components/alarm_control_panel/translations/en.json @@ -4,6 +4,7 @@ "arm_away": "Arm {entity_name} away", "arm_home": "Arm {entity_name} home", "arm_night": "Arm {entity_name} night", + "arm_vacation": "Arm {entity_name} vacation", "disarm": "Disarm {entity_name}", "trigger": "Trigger {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} is armed away", "is_armed_home": "{entity_name} is armed home", "is_armed_night": "{entity_name} is armed night", + "is_armed_vacation": "{entity_name} is armed vacation", "is_disarmed": "{entity_name} is disarmed", "is_triggered": "{entity_name} is triggered" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} armed away", "armed_home": "{entity_name} armed home", "armed_night": "{entity_name} armed night", + "armed_vacation": "{entity_name} armed vacation", "disarmed": "{entity_name} disarmed", "triggered": "{entity_name} triggered" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armed custom bypass", "armed_home": "Armed home", "armed_night": "Armed night", + "armed_vacation": "Armed vacation", "arming": "Arming", "disarmed": "Disarmed", "disarming": "Disarming", diff --git a/homeassistant/components/alarm_control_panel/translations/es.json b/homeassistant/components/alarm_control_panel/translations/es.json index ab4e4a20cce..a76c6bd5af9 100644 --- a/homeassistant/components/alarm_control_panel/translations/es.json +++ b/homeassistant/components/alarm_control_panel/translations/es.json @@ -4,6 +4,7 @@ "arm_away": "Armar {entity_name} exterior", "arm_home": "Armar {entity_name} modo casa", "arm_night": "Armar {entity_name} por la noche", + "arm_vacation": "Armar las vacaciones de {entity_name}", "disarm": "Desarmar {entity_name}", "trigger": "Lanzar {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} est\u00e1 armada ausente", "is_armed_home": "{entity_name} est\u00e1 armada en casa", "is_armed_night": "{entity_name} est\u00e1 armada noche", + "is_armed_vacation": "{entity_name} est\u00e1 armado de vacaciones", "is_disarmed": "{entity_name} est\u00e1 desarmada", "is_triggered": "{entity_name} est\u00e1 disparada" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} armada ausente", "armed_home": "{entity_name} armada en casa", "armed_night": "{entity_name} armada noche", + "armed_vacation": "Vacaciones armadas de {entity_name}", "disarmed": "{entity_name} desarmada", "triggered": "{entity_name} activado" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Armada personalizada", "armed_home": "Armada en casa", "armed_night": "Armada noche", + "armed_vacation": "Vacaciones armadas", "arming": "Armando", "disarmed": "Desarmada", "disarming": "Desarmando", diff --git a/homeassistant/components/alarm_control_panel/translations/et.json b/homeassistant/components/alarm_control_panel/translations/et.json index cc4bb6f1ea3..1c3ddf0e1b2 100644 --- a/homeassistant/components/alarm_control_panel/translations/et.json +++ b/homeassistant/components/alarm_control_panel/translations/et.json @@ -4,6 +4,7 @@ "arm_away": "Valvesta {entity_name}", "arm_home": "Valvesta {entity_name} kodus re\u017eiimis", "arm_night": "Valvesta {entity_name} \u00f6\u00f6re\u017eiimis", + "arm_vacation": "Valvesta {entity_name} puhkusere\u017eiimis", "disarm": "V\u00f5ta {entity_name} valvest maha", "trigger": "K\u00e4ivita {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} on valvestatud", "is_armed_home": "{entity_name} on valvestatud kodure\u017eiimis", "is_armed_night": "{entity_name} on valvestatud \u00f6\u00f6re\u017eiimis", + "is_armed_vacation": "{entity_name} on valvestatud puhkuse reziimis", "is_disarmed": "{entity_name} on valve alt maas", "is_triggered": "{entity_name} on h\u00e4iret andnud" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} valvestati", "armed_home": "{entity_name} valvestati kodure\u017eiimis", "armed_night": "{entity_name} valvestati \u00f6\u00f6re\u017eiimis", + "armed_vacation": "{entity_name} puhkuse re\u017eiim", "disarmed": "{entity_name} v\u00f5eti valvest maha", "triggered": "{entity_name} andis h\u00e4iret" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Valves, eranditega", "armed_home": "Valves kodus", "armed_night": "Valves \u00f6ine", + "armed_vacation": "Valvestatud puhkuse re\u017eiimis", "arming": "Valvestab", "disarmed": "Maas", "disarming": "Maas...", diff --git a/homeassistant/components/alarm_control_panel/translations/fr.json b/homeassistant/components/alarm_control_panel/translations/fr.json index c7e010e805e..6d8ee9c08c3 100644 --- a/homeassistant/components/alarm_control_panel/translations/fr.json +++ b/homeassistant/components/alarm_control_panel/translations/fr.json @@ -4,6 +4,7 @@ "arm_away": "Armer {entity_name} en mode \"sortie\"", "arm_home": "Armer {entity_name} en mode \"maison\"", "arm_night": "Armer {entity_name} en mode \"nuit\"", + "arm_vacation": "Armer {entity_name} vacances", "disarm": "D\u00e9sarmer {entity_name}", "trigger": "D\u00e9clencheur {entity_name}" }, @@ -29,6 +30,7 @@ "armed_custom_bypass": "Arm\u00e9 avec exception personnalis\u00e9e", "armed_home": "Enclench\u00e9e (pr\u00e9sent)", "armed_night": "Enclench\u00e9 (nuit)", + "armed_vacation": "Arm\u00e9es vacances", "arming": "Activation", "disarmed": "D\u00e9sactiv\u00e9e", "disarming": "D\u00e9sactivation", diff --git a/homeassistant/components/alarm_control_panel/translations/he.json b/homeassistant/components/alarm_control_panel/translations/he.json index 544b23f5629..836194caa80 100644 --- a/homeassistant/components/alarm_control_panel/translations/he.json +++ b/homeassistant/components/alarm_control_panel/translations/he.json @@ -2,15 +2,16 @@ "state": { "_": { "armed": "\u05d3\u05e8\u05d5\u05da", - "armed_away": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "armed_away": "\u05d3\u05e8\u05d5\u05da - \u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", "armed_custom_bypass": "\u05de\u05e2\u05e7\u05e3 \u05de\u05d5\u05ea\u05d0\u05dd \u05d0\u05d9\u05e9\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", "armed_home": "\u05d4\u05d1\u05d9\u05ea \u05d3\u05e8\u05d5\u05da", - "armed_night": "\u05d3\u05e8\u05d5\u05da \u05dc\u05d9\u05dc\u05d4", + "armed_night": "\u05d3\u05e8\u05d5\u05da - \u05dc\u05d9\u05dc\u05d4", + "armed_vacation": "\u05d3\u05e8\u05d5\u05da - \u05d7\u05d5\u05e4\u05e9\u05d4", "arming": "\u05de\u05e4\u05e2\u05d9\u05dc", "disarmed": "\u05de\u05e0\u05d5\u05d8\u05e8\u05dc", "disarming": "\u05de\u05e0\u05d8\u05e8\u05dc", "pending": "\u05de\u05de\u05ea\u05d9\u05df", - "triggered": "\u05d4\u05d5\u05e4\u05e2\u05dc" + "triggered": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05dc\u05d5\u05d7 \u05d1\u05e7\u05e8\u05d4 \u05e9\u05dc \u05d0\u05d6\u05e2\u05e7\u05d4" diff --git a/homeassistant/components/alarm_control_panel/translations/hu.json b/homeassistant/components/alarm_control_panel/translations/hu.json index 81fa10311ef..961006938d9 100644 --- a/homeassistant/components/alarm_control_panel/translations/hu.json +++ b/homeassistant/components/alarm_control_panel/translations/hu.json @@ -4,13 +4,18 @@ "arm_away": "{entity_name} \u00e9les\u00edt\u00e9se t\u00e1voz\u00f3 m\u00f3dban", "arm_home": "{entity_name} \u00e9les\u00edt\u00e9se otthon marad\u00f3 m\u00f3dban", "arm_night": "{entity_name} \u00e9les\u00edt\u00e9se \u00e9jszakai m\u00f3dban", + "arm_vacation": "\u00c9les\u00edtse az {entity_name} a nyaral\u00e1sra", "disarm": "{entity_name} hat\u00e1stalan\u00edt\u00e1sa", "trigger": "{entity_name} riaszt\u00e1si esem\u00e9ny ind\u00edt\u00e1sa" }, + "condition_type": { + "is_armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edtve" + }, "trigger_type": { "armed_away": "{entity_name} t\u00e1voz\u00f3 m\u00f3dban lett \u00e9les\u00edtve", "armed_home": "{entity_name} otthon marad\u00f3 m\u00f3dban lett \u00e9les\u00edtve", "armed_night": "{entity_name} \u00e9jszakai m\u00f3dban lett \u00e9les\u00edtve", + "armed_vacation": "{entity_name} nyaral\u00e1s \u00e9les\u00edt\u00e9s", "disarmed": "{entity_name} hat\u00e1stalan\u00edtva lett", "triggered": "{entity_name} riaszt\u00e1sba ker\u00fclt" } @@ -22,6 +27,7 @@ "armed_custom_bypass": "\u00c9les\u00edtve \u00e1thidal\u00e1ssal", "armed_home": "\u00c9les\u00edtve otthon", "armed_night": "\u00c9les\u00edtve \u00e9jszaka", + "armed_vacation": "Vak\u00e1ci\u00f3 \u00e9les\u00edt\u00e9s", "arming": "\u00c9les\u00edt\u00e9s", "disarmed": "Hat\u00e1stalan\u00edtva", "disarming": "Hat\u00e1stalan\u00edt\u00e1s", diff --git a/homeassistant/components/alarm_control_panel/translations/it.json b/homeassistant/components/alarm_control_panel/translations/it.json index 1574f88541b..2015dc0e09d 100644 --- a/homeassistant/components/alarm_control_panel/translations/it.json +++ b/homeassistant/components/alarm_control_panel/translations/it.json @@ -4,6 +4,7 @@ "arm_away": "Armare {entity_name} uscito", "arm_home": "Armare {entity_name} casa", "arm_night": "Armare {entity_name} notte", + "arm_vacation": "Armare {entity_name} vacanza", "disarm": "Disarmare {entity_name}", "trigger": "Attivazione {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} \u00e8 attivo in modalit\u00e0 fuori casa", "is_armed_home": "{entity_name} \u00e8 attivo in modalit\u00e0 a casa", "is_armed_night": "{entity_name} \u00e8 attivo in modalit\u00e0 notte", + "is_armed_vacation": "{entity_name} \u00e8 attivo in modalit\u00e0 vacanza", "is_disarmed": "{entity_name} \u00e8 disattivo", "is_triggered": "{entity_name} \u00e8 attivato" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} attivato in modalit\u00e0 fuori casa", "armed_home": "{entity_name} attivato in modalit\u00e0 a casa", "armed_night": "{entity_name} attivato in modalit\u00e0 notte", + "armed_vacation": "{entity_name} attivato in modalit\u00e0 vacanza", "disarmed": "{entity_name} disattivato", "triggered": "{entity_name} attivato" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Attivo con bypass personalizzato", "armed_home": "Attivo in casa", "armed_night": "Attivo Notte", + "armed_vacation": "Attivo Vacanza", "arming": "In Attivazione", "disarmed": "Disattivo", "disarming": "In Disattivazione", diff --git a/homeassistant/components/alarm_control_panel/translations/nl.json b/homeassistant/components/alarm_control_panel/translations/nl.json index 0a0f33d6181..65b7cf1a4b8 100644 --- a/homeassistant/components/alarm_control_panel/translations/nl.json +++ b/homeassistant/components/alarm_control_panel/translations/nl.json @@ -4,6 +4,7 @@ "arm_away": "Inschakelen {entity_name} afwezig", "arm_home": "Inschakelen {entity_name} thuis", "arm_night": "Inschakelen {entity_name} nacht", + "arm_vacation": "Schakel {entity_name} in op vakantie", "disarm": "Uitschakelen {entity_name}", "trigger": "Trigger {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} afwezig ingeschakeld", "is_armed_home": "{entity_name} thuis ingeschakeld", "is_armed_night": "{entity_name} nachtstand ingeschakeld", + "is_armed_vacation": "{entity_name} is in vakantie geschakeld", "is_disarmed": "{entity_name} is uitgeschakeld", "is_triggered": "{entity_name} wordt geactiveerd" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} afwezig ingeschakeld", "armed_home": "{entity_name} thuis ingeschakeld", "armed_night": "{entity_name} nachtstand ingeschakeld", + "armed_vacation": "{entity_name} schakelde vakantie in", "disarmed": "{entity_name} uitgeschakeld", "triggered": "{entity_name} geactiveerd" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "Ingeschakeld met overbrugging(en)", "armed_home": "Ingeschakeld thuis", "armed_night": "Ingeschakeld nacht", + "armed_vacation": "Vakantie ingeschakeld", "arming": "Schakelt in", "disarmed": "Uitgeschakeld", "disarming": "Schakelt uit", diff --git a/homeassistant/components/alarm_control_panel/translations/pl.json b/homeassistant/components/alarm_control_panel/translations/pl.json index 0fd3045d1df..b65dbd59282 100644 --- a/homeassistant/components/alarm_control_panel/translations/pl.json +++ b/homeassistant/components/alarm_control_panel/translations/pl.json @@ -4,6 +4,7 @@ "arm_away": "uzbr\u00f3j (poza domem) {entity_name}", "arm_home": "uzbr\u00f3j (w domu) {entity_name}", "arm_night": "uzbr\u00f3j (noc) {entity_name}", + "arm_vacation": "uzbr\u00f3j (tryb wakacyjny) {entity_name}", "disarm": "rozbr\u00f3j {entity_name}", "trigger": "wyzw\u00f3l {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "alarm {entity_name} jest uzbrojony (poza domem)", "is_armed_home": "alarm {entity_name} jest uzbrojony (w domu)", "is_armed_night": "alarm {entity_name} jest uzbrojony (noc)", + "is_armed_vacation": "alarm {entity_name} jest uzbrojony (tryb wakacyjny)", "is_disarmed": "alarm {entity_name} jest rozbrojony", "is_triggered": "alarm {entity_name} jest wyzwolony" }, @@ -18,6 +20,7 @@ "armed_away": "alarm {entity_name} zostanie uzbrojony (poza domem)", "armed_home": "alarm {entity_name} zostanie uzbrojony (w domu)", "armed_night": "alarm {entity_name} zostanie uzbrojony (noc)", + "armed_vacation": "alarm {entity_name} zostanie uzbrojony (tryb wakacyjny)", "disarmed": "alarm {entity_name} zostanie rozbrojony", "triggered": "alarm {entity_name} zostanie wyzwolony" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "uzbrojony (cz\u0119\u015bciowo)", "armed_home": "uzbrojony (w domu)", "armed_night": "uzbrojony (noc)", + "armed_vacation": "uzbrojony (tryb wakacyjny)", "arming": "uzbrajanie", "disarmed": "rozbrojony", "disarming": "rozbrajanie", diff --git a/homeassistant/components/alarm_control_panel/translations/ru.json b/homeassistant/components/alarm_control_panel/translations/ru.json index f390f017328..61e46b3db1f 100644 --- a/homeassistant/components/alarm_control_panel/translations/ru.json +++ b/homeassistant/components/alarm_control_panel/translations/ru.json @@ -4,6 +4,7 @@ "arm_away": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "arm_home": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "arm_night": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "arm_vacation": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "disarm": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0445\u0440\u0430\u043d\u0443 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "trigger": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" }, @@ -11,6 +12,7 @@ "is_armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "is_armed_vacation": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "is_triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" }, @@ -18,6 +20,7 @@ "armed_away": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u0435 \u0434\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "armed_home": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u0414\u043e\u043c\u0430\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "armed_night": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041d\u043e\u0447\u044c\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", + "armed_vacation": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d \u0440\u0435\u0436\u0438\u043c \u043e\u0445\u0440\u0430\u043d\u044b \"\u041e\u0442\u043f\u0443\u0441\u043a\" \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "disarmed": "\u041e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0430 \u043d\u0430 \u043f\u0430\u043d\u0435\u043b\u0438 {entity_name}", "triggered": "{entity_name} \u0441\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u0435\u0442" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "\u041e\u0445\u0440\u0430\u043d\u0430 \u0441 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u043c\u0438", "armed_home": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u0434\u043e\u043c\u0430)", "armed_night": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043d\u043e\u0447\u044c)", + "armed_vacation": "\u041e\u0445\u0440\u0430\u043d\u0430 (\u043e\u0442\u043f\u0443\u0441\u043a)", "arming": "\u041f\u043e\u0441\u0442\u0430\u043d\u043e\u0432\u043a\u0430 \u043d\u0430 \u043e\u0445\u0440\u0430\u043d\u0443", "disarmed": "\u0421\u043d\u044f\u0442\u043e \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", "disarming": "\u0421\u043d\u044f\u0442\u0438\u0435 \u0441 \u043e\u0445\u0440\u0430\u043d\u044b", diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hant.json b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json index 2dac00f9990..74d046c233d 100644 --- a/homeassistant/components/alarm_control_panel/translations/zh-Hant.json +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hant.json @@ -4,6 +4,7 @@ "arm_away": "\u8a2d\u5b9a{entity_name}\u5916\u51fa\u6a21\u5f0f", "arm_home": "\u8a2d\u5b9a{entity_name}\u8fd4\u5bb6\u6a21\u5f0f", "arm_night": "\u8a2d\u5b9a{entity_name}\u591c\u9593\u6a21\u5f0f", + "arm_vacation": "\u8a2d\u5b9a{entity_name}\u5ea6\u5047\u6a21\u5f0f", "disarm": "\u89e3\u9664{entity_name}", "trigger": "\u89f8\u767c{entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", "is_armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", "is_armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "is_armed_vacation": "{entity_name}\u8a2d\u5b9a\u5ea6\u5047", "is_disarmed": "{entity_name}\u5df2\u89e3\u9664", "is_triggered": "{entity_name}\u5df2\u89f8\u767c" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name}\u8a2d\u5b9a\u5916\u51fa", "armed_home": "{entity_name}\u8a2d\u5b9a\u5728\u5bb6", "armed_night": "{entity_name}\u8a2d\u5b9a\u591c\u9593", + "armed_vacation": "{entity_name}\u8a2d\u5b9a\u5ea6\u5047", "disarmed": "{entity_name}\u5df2\u89e3\u9664", "triggered": "{entity_name}\u5df2\u89f8\u767c" } @@ -29,6 +32,7 @@ "armed_custom_bypass": "\u8b66\u6212\u6a21\u5f0f\u72c0\u614b", "armed_home": "\u5728\u5bb6\u8b66\u6212", "armed_night": "\u591c\u9593\u8b66\u6212", + "armed_vacation": "\u5ea6\u5047\u8b66\u6212", "arming": "\u8b66\u6212\u4e2d", "disarmed": "\u8b66\u6212\u89e3\u9664", "disarming": "\u89e3\u9664\u4e2d", diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py index aff7dd8c5ba..69870450869 100644 --- a/homeassistant/components/alarmdecoder/__init__.py +++ b/homeassistant/components/alarmdecoder/__init__.py @@ -129,7 +129,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a AlarmDecoder entry.""" hass.data[DOMAIN][entry.entry_id][DATA_RESTART] = False diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 47da48de66f..3a0b3923231 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -77,24 +77,18 @@ async def async_setup_entry( class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" + _attr_name = "Alarm Panel" + _attr_should_poll = False + _attr_code_format = FORMAT_NUMBER + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + def __init__(self, client, auto_bypass, code_arm_required, alt_night_mode): """Initialize the alarm panel.""" self._client = client - self._display = "" - self._name = "Alarm Panel" - self._state = None - self._ac_power = None - self._alarm_event_occurred = None - self._backlight_on = None - self._battery_low = None - self._check_zone = None - self._chime = None - self._entry_delay_off = None - self._programming_mode = None - self._ready = None - self._zone_bypassed = None self._auto_bypass = auto_bypass - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._alt_night_mode = alt_night_mode async def async_added_to_hass(self): @@ -108,75 +102,29 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): def _message_callback(self, message): """Handle received messages.""" if message.alarm_sounding or message.fire_alarm: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED elif message.armed_away: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif message.armed_home and (message.entry_delay_off or message.perimeter_only): - self._state = STATE_ALARM_ARMED_NIGHT + self._attr_state = STATE_ALARM_ARMED_NIGHT elif message.armed_home: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED - self._ac_power = message.ac_power - self._alarm_event_occurred = message.alarm_event_occurred - self._backlight_on = message.backlight_on - self._battery_low = message.battery_low - self._check_zone = message.check_zone - self._chime = message.chime_on - self._entry_delay_off = message.entry_delay_off - self._programming_mode = message.programming_mode - self._ready = message.ready - self._zone_bypassed = message.zone_bypassed - - self.schedule_update_ha_state() - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return one or more digits/characters.""" - return FORMAT_NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - "ac_power": self._ac_power, - "alarm_event_occurred": self._alarm_event_occurred, - "backlight_on": self._backlight_on, - "battery_low": self._battery_low, - "check_zone": self._check_zone, - "chime": self._chime, - "entry_delay_off": self._entry_delay_off, - "programming_mode": self._programming_mode, - "ready": self._ready, - "zone_bypassed": self._zone_bypassed, - "code_arm_required": self._code_arm_required, + self._attr_extra_state_attributes = { + "ac_power": message.ac_power, + "alarm_event_occurred": message.alarm_event_occurred, + "backlight_on": message.backlight_on, + "battery_low": message.battery_low, + "check_zone": message.check_zone, + "chime": message.chime_on, + "entry_delay_off": message.entry_delay_off, + "programming_mode": message.programming_mode, + "ready": message.ready, + "zone_bypassed": message.zone_bypassed, } + self.schedule_update_ha_state() def alarm_disarm(self, code=None): """Send disarm command.""" @@ -187,7 +135,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Send arm away command.""" self._client.arm_away( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, auto_bypass=self._auto_bypass, ) @@ -195,7 +143,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Send arm home command.""" self._client.arm_home( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, auto_bypass=self._auto_bypass, ) @@ -203,7 +151,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): """Send arm night command.""" self._client.arm_night( code=code, - code_arm_required=self._code_arm_required, + code_arm_required=self._attr_code_arm_required, alt_night_mode=self._alt_night_mode, auto_bypass=self._auto_bypass, ) diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py index 71bcc399e08..397394e256b 100644 --- a/homeassistant/components/alarmdecoder/binary_sensor.py +++ b/homeassistant/components/alarmdecoder/binary_sensor.py @@ -60,6 +60,8 @@ async def async_setup_entry( class AlarmDecoderBinarySensor(BinarySensorEntity): """Representation of an AlarmDecoder binary sensor.""" + _attr_should_poll = False + def __init__( self, zone_number, @@ -73,13 +75,12 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): """Initialize the binary_sensor.""" self._zone_number = int(zone_number) self._zone_type = zone_type - self._state = None - self._name = zone_name + self._attr_name = zone_name self._rfid = zone_rfid self._loop = zone_loop - self._rfstate = None self._relay_addr = relay_addr self._relay_chan = relay_chan + self._attr_device_class = zone_type async def async_added_to_hass(self): """Register callbacks.""" @@ -107,59 +108,35 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): ) ) - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attr = {CONF_ZONE_NUMBER: self._zone_number} - if self._rfid and self._rfstate is not None: - attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01) - attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02) - attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04) - attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08) - attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10) - attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20) - attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40) - attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80) - return attr - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._state == 1 - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return self._zone_type - def _fault_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or int(zone) == self._zone_number: - self._state = 1 + self._attr_state = 1 self.schedule_update_ha_state() def _restore_callback(self, zone): """Update the zone's state, if needed.""" if zone is None or (int(zone) == self._zone_number and not self._loop): - self._state = 0 + self._attr_state = 0 self.schedule_update_ha_state() def _rfx_message_callback(self, message): """Update RF state.""" if self._rfid and message and message.serial_number == self._rfid: - self._rfstate = message.value + rfstate = message.value if self._loop: - self._state = 1 if message.loop[self._loop - 1] else 0 + self._attr_state = 1 if message.loop[self._loop - 1] else 0 + attr = {CONF_ZONE_NUMBER: self._zone_number} + if self._rfid and rfstate is not None: + attr[ATTR_RF_BIT0] = bool(rfstate & 0x01) + attr[ATTR_RF_LOW_BAT] = bool(rfstate & 0x02) + attr[ATTR_RF_SUPERVISED] = bool(rfstate & 0x04) + attr[ATTR_RF_BIT3] = bool(rfstate & 0x08) + attr[ATTR_RF_LOOP3] = bool(rfstate & 0x10) + attr[ATTR_RF_LOOP2] = bool(rfstate & 0x20) + attr[ATTR_RF_LOOP4] = bool(rfstate & 0x40) + attr[ATTR_RF_LOOP1] = bool(rfstate & 0x80) + self._attr_extra_state_attributes = attr self.schedule_update_ha_state() def _rel_message_callback(self, message): @@ -173,5 +150,5 @@ class AlarmDecoderBinarySensor(BinarySensorEntity): message.channel, message.value, ) - self._state = message.value + self._attr_state = message.value self.schedule_update_ha_state() diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index cc4a19e4755..45d04feb3b2 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -299,7 +299,7 @@ def _validate_zone_input(zone_input): errors["base"] = "relay_inclusive" # The following keys must be int - for key in [CONF_ZONE_NUMBER, CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN]: + for key in (CONF_ZONE_NUMBER, CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN): if key in zone_input: try: int(zone_input[key]) @@ -328,7 +328,7 @@ def _fix_input_types(zone_input): strings and then convert them to ints. """ - for key in [CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN]: + for key in (CONF_ZONE_LOOP, CONF_RELAY_ADDR, CONF_RELAY_CHAN): if key in zone_input: zone_input[key] = int(zone_input[key]) diff --git a/homeassistant/components/alarmdecoder/const.py b/homeassistant/components/alarmdecoder/const.py index f1bfb66f0d4..4aba16a9cf8 100644 --- a/homeassistant/components/alarmdecoder/const.py +++ b/homeassistant/components/alarmdecoder/const.py @@ -32,7 +32,7 @@ DEFAULT_ARM_OPTIONS = { CONF_AUTO_BYPASS: DEFAULT_AUTO_BYPASS, CONF_CODE_ARM_REQUIRED: DEFAULT_CODE_ARM_REQUIRED, } -DEFAULT_ZONE_OPTIONS = {} +DEFAULT_ZONE_OPTIONS: dict = {} DOMAIN = "alarmdecoder" diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py index e3c85cb5893..67b7ee4861a 100644 --- a/homeassistant/components/alarmdecoder/sensor.py +++ b/homeassistant/components/alarmdecoder/sensor.py @@ -8,7 +8,7 @@ from .const import SIGNAL_PANEL_MESSAGE async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities -): +) -> bool: """Set up for AlarmDecoder sensor.""" entity = AlarmDecoderSensor() @@ -19,12 +19,9 @@ async def async_setup_entry( class AlarmDecoderSensor(SensorEntity): """Representation of an AlarmDecoder keypad.""" - def __init__(self): - """Initialize the alarm panel.""" - self._display = "" - self._state = None - self._icon = "mdi:alarm-check" - self._name = "Alarm Panel Display" + _attr_icon = "mdi:alarm-check" + _attr_name = "Alarm Panel Display" + _attr_should_poll = False async def async_added_to_hass(self): """Register callbacks.""" @@ -35,26 +32,6 @@ class AlarmDecoderSensor(SensorEntity): ) def _message_callback(self, message): - if self._display != message.text: - self._display = message.text + if self._attr_state != message.text: + self._attr_state = message.text self.schedule_update_ha_state() - - @property - def icon(self): - """Return the icon if any.""" - return self._icon - - @property - def state(self): - """Return the overall state.""" - return self._display - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False diff --git a/homeassistant/components/alarmdecoder/translations/ar.json b/homeassistant/components/alarmdecoder/translations/ar.json new file mode 100644 index 00000000000..1f49f618afb --- /dev/null +++ b/homeassistant/components/alarmdecoder/translations/ar.json @@ -0,0 +1,41 @@ +{ + "config": { + "step": { + "protocol": { + "data": { + "device_baudrate": "\u0645\u0639\u062f\u0644 \u0633\u0631\u0639\u0629 \u0627\u0644\u0628\u062b \u0644\u0644\u062c\u0647\u0627\u0632", + "device_path": "\u0645\u0633\u0627\u0631 \u0627\u0644\u062c\u0647\u0627\u0632" + }, + "title": "\u062a\u0643\u0648\u064a\u0646 \u0625\u0639\u062f\u0627\u062f\u0627\u062a \u0627\u0644\u0627\u062a\u0635\u0627\u0644" + }, + "user": { + "data": { + "protocol": "\u0628\u0631\u0648\u062a\u0648\u0643\u0648\u0644" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "edit_select": "\u062a\u0639\u062f\u064a\u0644" + }, + "description": "\u0645\u0627 \u0627\u0644\u0630\u064a \u062a\u0631\u064a\u062f \u062a\u0639\u062f\u064a\u0644\u0647\u061f", + "title": "\u062a\u0643\u0648\u064a\u0646 AlarmDecoder" + }, + "zone_details": { + "data": { + "zone_name": "\u0627\u0633\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629", + "zone_type": "\u0646\u0648\u0639 \u0627\u0644\u0645\u0646\u0637\u0642\u0629" + }, + "title": "\u062a\u0643\u0648\u064a\u0646 AlarmDecoder" + }, + "zone_select": { + "data": { + "zone_number": "\u0631\u0642\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/alarmdecoder/translations/de.json b/homeassistant/components/alarmdecoder/translations/de.json index 4936cce4dfd..f324772c909 100644 --- a/homeassistant/components/alarmdecoder/translations/de.json +++ b/homeassistant/components/alarmdecoder/translations/de.json @@ -23,7 +23,7 @@ "data": { "protocol": "Protokoll" }, - "title": "W\u00e4hlen Sie das AlarmDecoder-Protokoll" + "title": "W\u00e4hle das AlarmDecoder-Protokoll" } } }, @@ -59,7 +59,7 @@ "zone_rfid": "RF Serial", "zone_type": "Zonentyp" }, - "description": "Geben Sie Details f\u00fcr Zone {zone_number} ein. Um Zone {zone_number} zu l\u00f6schen, lassen Sie Zonenname leer.", + "description": "Gib Details f\u00fcr Zone {zone_number} ein. Um Zone {zone_number} zu l\u00f6schen, lass den Zonennamen leer.", "title": "AlarmDecoder konfigurieren" }, "zone_select": { diff --git a/homeassistant/components/alarmdecoder/translations/he.json b/homeassistant/components/alarmdecoder/translations/he.json index e130a1997b2..db754768f48 100644 --- a/homeassistant/components/alarmdecoder/translations/he.json +++ b/homeassistant/components/alarmdecoder/translations/he.json @@ -28,7 +28,7 @@ "step": { "init": { "data": { - "edit_select": "\u05e2\u05e8\u05d5\u05da" + "edit_select": "\u05e2\u05e8\u05d9\u05db\u05d4" } }, "zone_details": { diff --git a/homeassistant/components/alarmdecoder/translations/hu.json b/homeassistant/components/alarmdecoder/translations/hu.json index 8c80adcb3c0..47db325f06c 100644 --- a/homeassistant/components/alarmdecoder/translations/hu.json +++ b/homeassistant/components/alarmdecoder/translations/hu.json @@ -12,28 +12,60 @@ "step": { "protocol": { "data": { + "device_baudrate": "Eszk\u00f6z \u00e1tviteli sebess\u00e9ge", + "device_path": "Eszk\u00f6z el\u00e9r\u00e9si \u00fatja", "host": "Hoszt", "port": "Port" - } + }, + "title": "Konfigur\u00e1lja a csatlakoz\u00e1si be\u00e1ll\u00edt\u00e1sokat" }, "user": { "data": { "protocol": "Protokoll" - } + }, + "title": "V\u00e1lassza ki a AlarmDecoder protokollt" } } }, "options": { + "error": { + "int": "Az al\u00e1bbi mez\u0151nek eg\u00e9sz sz\u00e1mnak kell lennie.", + "loop_range": "Az RF hurok eg\u00e9sz sz\u00e1m\u00e1nak 1 \u00e9s 4 k\u00f6z\u00f6tt kell lennie.", + "relay_inclusive": "A rel\u00e9c\u00edm \u00e9s a rel\u00e9csatorna egym\u00e1st\u00f3l f\u00fcgg, \u00e9s egy\u00fctt kell felt\u00fcntetni." + }, "step": { + "arm_settings": { + "data": { + "alt_night_mode": "Alternat\u00edv \u00e9jszakai m\u00f3d", + "auto_bypass": "Automatikus egy\u00e9ni \u00e9les\u00edt\u00e9s", + "code_arm_required": "Az \u00e9les\u00edt\u00e9shez sz\u00fcks\u00e9ges k\u00f3d" + }, + "title": "Konfigur\u00e1lja az AlarmDecodert" + }, "init": { "data": { "edit_select": "Szerkeszt\u00e9s" - } + }, + "description": "Mit szeretn\u00e9l szerkeszteni?", + "title": "Konfigur\u00e1lja az AlarmDecodert" }, "zone_details": { "data": { - "zone_name": "Z\u00f3na neve" - } + "zone_loop": "RF hurok", + "zone_name": "Z\u00f3na neve", + "zone_relayaddr": "Rel\u00e9 c\u00edm", + "zone_relaychan": "Rel\u00e9 csatorna", + "zone_type": "Z\u00f3na t\u00edpusa" + }, + "description": "Adja meg a {zone_number} z\u00f3na adatait. {zone_number} z\u00f3na t\u00f6rl\u00e9s\u00e9hez hagyja \u00fcresen a Z\u00f3na neve elemet.", + "title": "Konfigur\u00e1lja az AlarmDecodert" + }, + "zone_select": { + "data": { + "zone_number": "Z\u00f3na sz\u00e1ma" + }, + "description": "\u00cdrja be a hozz\u00e1adni, szerkeszteni vagy elt\u00e1vol\u00edtani k\u00edv\u00e1nt z\u00f3nasz\u00e1mot.", + "title": "Konfigur\u00e1lja az AlarmDecodert" } } } diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 73bea193394..73be34e6d33 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -48,7 +48,12 @@ ALERT_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Required(CONF_STATE, default=STATE_ON): cv.string, - vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]), + vol.Required(CONF_REPEAT): vol.All( + cv.ensure_list, + [vol.Coerce(float)], + # Minimum delay is 1 second = 0.016 minutes + [vol.Range(min=0.016)], + ), vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean, vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean, vol.Optional(CONF_ALERT_MESSAGE): cv.template, @@ -152,6 +157,8 @@ async def async_setup(hass, config): class Alert(ToggleEntity): """Representation of an alert.""" + _attr_should_poll = False + def __init__( self, hass, @@ -170,7 +177,7 @@ class Alert(ToggleEntity): ): """Initialize the alert.""" self.hass = hass - self._name = name + self._attr_name = name self._alert_state = state self._skip_first = skip_first self._data = data @@ -203,16 +210,6 @@ class Alert(ToggleEntity): hass, [watched_entity_id], self.watched_entity_change ) - @property - def name(self): - """Return the name of the alert.""" - return self._name - - @property - def should_poll(self): - """Home Assistant need not poll these entities.""" - return False - @property def state(self): """Return the alert status.""" @@ -235,7 +232,7 @@ class Alert(ToggleEntity): async def begin_alerting(self): """Begin the alert procedures.""" - _LOGGER.debug("Beginning Alert: %s", self._name) + _LOGGER.debug("Beginning Alert: %s", self._attr_name) self._ack = False self._firing = True self._next_delay = 0 @@ -249,7 +246,7 @@ class Alert(ToggleEntity): async def end_alerting(self): """End the alert procedures.""" - _LOGGER.debug("Ending Alert: %s", self._name) + _LOGGER.debug("Ending Alert: %s", self._attr_name) self._cancel() self._ack = False self._firing = False @@ -272,13 +269,13 @@ class Alert(ToggleEntity): return if not self._ack: - _LOGGER.info("Alerting: %s", self._name) + _LOGGER.info("Alerting: %s", self._attr_name) self._send_done_message = True if self._message_template is not None: message = self._message_template.async_render(parse_result=False) else: - message = self._name + message = self._attr_name await self._send_notification_message(message) await self._schedule_notify() @@ -314,13 +311,13 @@ class Alert(ToggleEntity): async def async_turn_on(self, **kwargs): """Async Unacknowledge alert.""" - _LOGGER.debug("Reset Alert: %s", self._name) + _LOGGER.debug("Reset Alert: %s", self._attr_name) self._ack = False self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Async Acknowledge alert.""" - _LOGGER.debug("Acknowledged Alert: %s", self._name) + _LOGGER.debug("Acknowledged Alert: %s", self._attr_name) self._ack = True self.async_write_ha_state() diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 10b382c8dcf..db1fa990c54 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -19,6 +19,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_NIGHT, ) import homeassistant.components.climate.const as climate +from homeassistant.components.lock import STATE_LOCKING, STATE_UNLOCKING import homeassistant.components.media_player.const as media_player from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, @@ -446,9 +447,11 @@ class AlexaLockController(AlexaCapability): if name != "lockState": raise UnsupportedProperty(name) - if self.entity.state == STATE_LOCKED: + # If its unlocking its still locked and not unlocked yet + if self.entity.state in (STATE_UNLOCKING, STATE_LOCKED): return "LOCKED" - if self.entity.state == STATE_UNLOCKED: + # If its locking its still unlocked and not locked yet + if self.entity.state in (STATE_LOCKING, STATE_UNLOCKED): return "UNLOCKED" return "JAMMED" diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 0788772a45b..512de247ff2 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -110,48 +110,27 @@ class AlphaVantageSensor(SensorEntity): def __init__(self, timeseries, symbol): """Initialize the sensor.""" self._symbol = symbol[CONF_SYMBOL] - self._name = symbol.get(CONF_NAME, self._symbol) + self._attr_name = symbol.get(CONF_NAME, self._symbol) self._timeseries = timeseries - self.values = None - self._unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) - self._icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - return self.values["1. open"] - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self.values is not None: - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_CLOSE: self.values["4. close"], - ATTR_HIGH: self.values["2. high"], - ATTR_LOW: self.values["3. low"], - } - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon + self._attr_unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol) + self._attr_icon = ICONS.get(symbol.get(CONF_CURRENCY, "USD")) def update(self): """Get the latest data and updates the states.""" _LOGGER.debug("Requesting new data for symbol %s", self._symbol) all_values, _ = self._timeseries.get_intraday(self._symbol) - self.values = next(iter(all_values.values())) + values = next(iter(all_values.values())) + self._attr_state = values["1. open"] + self._attr_extra_state_attributes = ( + { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_CLOSE: values["4. close"], + ATTR_HIGH: values["2. high"], + ATTR_LOW: values["3. low"], + } + if values is not None + else None + ) _LOGGER.debug("Received new values for symbol %s", self._symbol) @@ -163,43 +142,13 @@ class AlphaVantageForeignExchange(SensorEntity): self._foreign_exchange = foreign_exchange self._from_currency = config[CONF_FROM] self._to_currency = config[CONF_TO] - if CONF_NAME in config: - self._name = config.get(CONF_NAME) - else: - self._name = f"{self._to_currency}/{self._from_currency}" - self._unit_of_measurement = self._to_currency - self._icon = ICONS.get(self._from_currency, "USD") - self.values = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - return round(float(self.values["5. Exchange Rate"]), 4) - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self.values is not None: - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - CONF_FROM: self._from_currency, - CONF_TO: self._to_currency, - } + self._attr_name = ( + config.get(CONF_NAME) + if CONF_NAME in config + else f"{self._to_currency}/{self._from_currency}" + ) + self._attr_icon = ICONS.get(self._from_currency, "USD") + self._attr_unit_of_measurement = self._to_currency def update(self): """Get the latest data and updates the states.""" @@ -208,9 +157,20 @@ class AlphaVantageForeignExchange(SensorEntity): self._from_currency, self._to_currency, ) - self.values, _ = self._foreign_exchange.get_currency_exchange_rate( + values, _ = self._foreign_exchange.get_currency_exchange_rate( from_currency=self._from_currency, to_currency=self._to_currency ) + self._attr_state = round(float(values["5. Exchange Rate"]), 4) + self._attr_extra_state_attributes = ( + { + ATTR_ATTRIBUTION: ATTRIBUTION, + CONF_FROM: self._from_currency, + CONF_TO: self._to_currency, + } + if values is not None + else None + ) + _LOGGER.debug( "Received new data for forex %s - %s", self._from_currency, diff --git a/homeassistant/components/ambee/const.py b/homeassistant/components/ambee/const.py index 730c6780f4f..d2570bea710 100644 --- a/homeassistant/components/ambee/const.py +++ b/homeassistant/components/ambee/const.py @@ -5,12 +5,11 @@ from datetime import timedelta import logging from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_CUBIC_METER, @@ -18,13 +17,10 @@ from homeassistant.const import ( DEVICE_CLASS_CO, ) -from .models import AmbeeSensor - DOMAIN: Final = "ambee" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(hours=1) -ATTR_ENABLED_BY_DEFAULT: Final = "enabled_by_default" ATTR_ENTRY_TYPE: Final = "entry_type" ENTRY_TYPE_SERVICE: Final = "service" @@ -38,175 +34,202 @@ SERVICES: dict[str, str] = { SERVICE_POLLEN: "Pollen", } -SENSORS: dict[str, dict[str, AmbeeSensor]] = { - SERVICE_AIR_QUALITY: { - "particulate_matter_2_5": { - ATTR_NAME: "Particulate Matter < 2.5 μm", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "particulate_matter_10": { - ATTR_NAME: "Particulate Matter < 10 μm", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "sulphur_dioxide": { - ATTR_NAME: "Sulphur Dioxide (SO2)", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "nitrogen_dioxide": { - ATTR_NAME: "Nitrogen Dioxide (NO2)", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "ozone": { - ATTR_NAME: "Ozone", - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "carbon_monoxide": { - ATTR_NAME: "Carbon Monoxide (CO)", - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - "air_quality_index": { - ATTR_NAME: "Air Quality Index (AQI)", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - }, - SERVICE_POLLEN: { - "grass": { - ATTR_NAME: "Grass Pollen", - ATTR_ICON: "mdi:grass", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "tree": { - ATTR_NAME: "Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "weed": { - ATTR_NAME: "Weed Pollen", - ATTR_ICON: "mdi:sprout", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - }, - "grass_risk": { - ATTR_NAME: "Grass Pollen Risk", - ATTR_ICON: "mdi:grass", - ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, - }, - "tree_risk": { - ATTR_NAME: "Tree Pollen Risk", - ATTR_ICON: "mdi:tree", - ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, - }, - "weed_risk": { - ATTR_NAME: "Weed Pollen Risk", - ATTR_ICON: "mdi:sprout", - ATTR_DEVICE_CLASS: DEVICE_CLASS_AMBEE_RISK, - }, - "grass_poaceae": { - ATTR_NAME: "Poaceae Grass Pollen", - ATTR_ICON: "mdi:grass", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_alder": { - ATTR_NAME: "Alder Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_birch": { - ATTR_NAME: "Birch Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_cypress": { - ATTR_NAME: "Cypress Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_elm": { - ATTR_NAME: "Elm Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_hazel": { - ATTR_NAME: "Hazel Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_oak": { - ATTR_NAME: "Oak Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_pine": { - ATTR_NAME: "Pine Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_plane": { - ATTR_NAME: "Plane Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "tree_poplar": { - ATTR_NAME: "Poplar Tree Pollen", - ATTR_ICON: "mdi:tree", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "weed_chenopod": { - ATTR_NAME: "Chenopod Weed Pollen", - ATTR_ICON: "mdi:sprout", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "weed_mugwort": { - ATTR_NAME: "Mugwort Weed Pollen", - ATTR_ICON: "mdi:sprout", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "weed_nettle": { - ATTR_NAME: "Nettle Weed Pollen", - ATTR_ICON: "mdi:sprout", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - "weed_ragweed": { - ATTR_NAME: "Ragweed Weed Pollen", - ATTR_ICON: "mdi:sprout", - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_CUBIC_METER, - ATTR_ENABLED_BY_DEFAULT: False, - }, - }, +SENSORS: dict[str, list[SensorEntityDescription]] = { + SERVICE_AIR_QUALITY: [ + SensorEntityDescription( + key="particulate_matter_2_5", + name="Particulate Matter < 2.5 μm", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="particulate_matter_10", + name="Particulate Matter < 10 μm", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="sulphur_dioxide", + name="Sulphur Dioxide (SO2)", + unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="nitrogen_dioxide", + name="Nitrogen Dioxide (NO2)", + unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="ozone", + name="Ozone", + unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="carbon_monoxide", + name="Carbon Monoxide (CO)", + device_class=DEVICE_CLASS_CO, + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="air_quality_index", + name="Air Quality Index (AQI)", + state_class=STATE_CLASS_MEASUREMENT, + ), + ], + SERVICE_POLLEN: [ + SensorEntityDescription( + key="grass", + name="Grass Pollen", + icon="mdi:grass", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="tree", + name="Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="weed", + name="Weed Pollen", + icon="mdi:sprout", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + ), + SensorEntityDescription( + key="grass_risk", + name="Grass Pollen Risk", + icon="mdi:grass", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="tree_risk", + name="Tree Pollen Risk", + icon="mdi:tree", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="weed_risk", + name="Weed Pollen Risk", + icon="mdi:sprout", + device_class=DEVICE_CLASS_AMBEE_RISK, + ), + SensorEntityDescription( + key="grass_poaceae", + name="Poaceae Grass Pollen", + icon="mdi:grass", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_alder", + name="Alder Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_birch", + name="Birch Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_cypress", + name="Cypress Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_elm", + name="Elm Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_hazel", + name="Hazel Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_oak", + name="Oak Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_pine", + name="Pine Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_plane", + name="Plane Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="tree_poplar", + name="Poplar Tree Pollen", + icon="mdi:tree", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_chenopod", + name="Chenopod Weed Pollen", + icon="mdi:sprout", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_mugwort", + name="Mugwort Weed Pollen", + icon="mdi:sprout", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_nettle", + name="Nettle Weed Pollen", + icon="mdi:sprout", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weed_ragweed", + name="Ragweed Weed Pollen", + icon="mdi:sprout", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER, + entity_registry_enabled_default=False, + ), + ], } diff --git a/homeassistant/components/ambee/models.py b/homeassistant/components/ambee/models.py deleted file mode 100644 index 871aeed332b..00000000000 --- a/homeassistant/components/ambee/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Models helper class for the Ambee integration.""" -from __future__ import annotations - -from typing import TypedDict - - -class AmbeeSensor(TypedDict, total=False): - """Represent an Ambee Sensor.""" - - device_class: str - enabled_by_default: bool - icon: str - name: str - state_class: str - unit_of_measurement: str diff --git a/homeassistant/components/ambee/sensor.py b/homeassistant/components/ambee/sensor.py index 54e67160822..ecd04ffd204 100644 --- a/homeassistant/components/ambee/sensor.py +++ b/homeassistant/components/ambee/sensor.py @@ -2,19 +2,12 @@ from __future__ import annotations from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_NAME, - ATTR_UNIT_OF_MEASUREMENT, -) +from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -23,15 +16,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import ( - ATTR_ENABLED_BY_DEFAULT, - ATTR_ENTRY_TYPE, - DOMAIN, - ENTRY_TYPE_SERVICE, - SENSORS, - SERVICES, -) -from .models import AmbeeSensor +from .const import ATTR_ENTRY_TYPE, DOMAIN, ENTRY_TYPE_SERVICE, SENSORS, SERVICES async def async_setup_entry( @@ -44,13 +29,12 @@ async def async_setup_entry( AmbeeSensorEntity( coordinator=hass.data[DOMAIN][entry.entry_id][service_key], entry_id=entry.entry_id, - sensor_key=sensor_key, - sensor=sensor, + description=description, service_key=service_key, service=SERVICES[service_key], ) for service_key, service_sensors in SENSORS.items() - for sensor_key, sensor in service_sensors.items() + for description in service_sensors ) @@ -62,26 +46,17 @@ class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): *, coordinator: DataUpdateCoordinator, entry_id: str, - sensor_key: str, - sensor: AmbeeSensor, + description: SensorEntityDescription, service_key: str, service: str, ) -> None: """Initialize Ambee sensor.""" super().__init__(coordinator=coordinator) - self._sensor_key = sensor_key self._service_key = service_key - self.entity_id = f"{SENSOR_DOMAIN}.{service_key}_{sensor_key}" - self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) - self._attr_entity_registry_enabled_default = sensor.get( - ATTR_ENABLED_BY_DEFAULT, True - ) - self._attr_icon = sensor.get(ATTR_ICON) - self._attr_name = sensor.get(ATTR_NAME) - self._attr_state_class = sensor.get(ATTR_STATE_CLASS) - self._attr_unique_id = f"{entry_id}_{service_key}_{sensor_key}" - self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) + self.entity_id = f"{SENSOR_DOMAIN}.{service_key}_{description.key}" + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{service_key}_{description.key}" self._attr_device_info = { ATTR_IDENTIFIERS: {(DOMAIN, f"{entry_id}_{service_key}")}, @@ -93,7 +68,7 @@ class AmbeeSensorEntity(CoordinatorEntity, SensorEntity): @property def state(self) -> StateType: """Return the state of the sensor.""" - value = getattr(self.coordinator.data, self._sensor_key) + value = getattr(self.coordinator.data, self.entity_description.key) if isinstance(value, str): return value.lower() return value # type: ignore[no-any-return] diff --git a/homeassistant/components/ambee/translations/fr.json b/homeassistant/components/ambee/translations/fr.json new file mode 100644 index 00000000000..bbb09edf763 --- /dev/null +++ b/homeassistant/components/ambee/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_api_key": "Cl\u00e9 API non valide" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "cl\u00e9 API", + "description": "R\u00e9-authentifiez-vous avec votre compte Ambee." + } + }, + "user": { + "data": { + "api_key": "cl\u00e9 API", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom" + }, + "description": "Configurer Ambee pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/hu.json b/homeassistant/components/ambee/translations/hu.json index 556412764a2..4cf99c596f0 100644 --- a/homeassistant/components/ambee/translations/hu.json +++ b/homeassistant/components/ambee/translations/hu.json @@ -10,7 +10,8 @@ "step": { "reauth_confirm": { "data": { - "api_key": "API kulcs" + "api_key": "API kulcs", + "description": "Hiteles\u00edtse mag\u00e1t \u00fajra az Ambee-fi\u00f3kj\u00e1val." } }, "user": { @@ -19,7 +20,8 @@ "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" - } + }, + "description": "\u00c1ll\u00edtsa be az Ambee-t a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." } } } diff --git a/homeassistant/components/ambee/translations/id.json b/homeassistant/components/ambee/translations/id.json new file mode 100644 index 00000000000..ecf627579fe --- /dev/null +++ b/homeassistant/components/ambee/translations/id.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_api_key": "Kunci API tidak valid" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + } + }, + "user": { + "data": { + "api_key": "Kunci API", + "latitude": "Lintang", + "longitude": "Bujur", + "name": "Nama" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.fr.json b/homeassistant/components/ambee/translations/sensor.fr.json new file mode 100644 index 00000000000..76dc3fe6301 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.fr.json @@ -0,0 +1,10 @@ +{ + "state": { + "ambee__risk": { + "high": "Haute", + "low": "Faible", + "moderate": "Mod\u00e9rer", + "very high": "Tr\u00e8s \u00e9lev\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.he.json b/homeassistant/components/ambee/translations/sensor.he.json new file mode 100644 index 00000000000..14ae06f2bc9 --- /dev/null +++ b/homeassistant/components/ambee/translations/sensor.he.json @@ -0,0 +1,9 @@ +{ + "state": { + "ambee__risk": { + "high": "\u05d2\u05d1\u05d5\u05d4", + "low": "\u05e0\u05de\u05d5\u05da", + "very high": "\u05d2\u05d1\u05d5\u05d4 \u05de\u05d0\u05d5\u05d3" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambee/translations/sensor.hu.json b/homeassistant/components/ambee/translations/sensor.hu.json index aa86baf2722..975d200a507 100644 --- a/homeassistant/components/ambee/translations/sensor.hu.json +++ b/homeassistant/components/ambee/translations/sensor.hu.json @@ -3,6 +3,7 @@ "ambee__risk": { "high": "Magas", "low": "Alacsony", + "moderate": "M\u00e9rs\u00e9kelt", "very high": "Nagyon magas" } } diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py index e9247b9fd73..ac6334638a4 100644 --- a/homeassistant/components/ambiclimate/__init__.py +++ b/homeassistant/components/ambiclimate/__init__.py @@ -20,7 +20,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass, config) -> bool: """Set up Ambiclimate components.""" if DOMAIN not in config: return True @@ -34,7 +34,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass, entry) -> bool: """Set up Ambiclimate from a config entry.""" hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "climate") diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py index 93b38974464..8cfebb1bf69 100644 --- a/homeassistant/components/ambiclimate/climate.py +++ b/homeassistant/components/ambiclimate/climate.py @@ -1,6 +1,7 @@ """Support for Ambiclimate ac.""" import asyncio import logging +from typing import Any import ambiclimate import voluptuous as vol @@ -137,92 +138,33 @@ async def async_setup_entry(hass, entry, async_add_entities): class AmbiclimateEntity(ClimateEntity): """Representation of a Ambiclimate Thermostat device.""" + _attr_temperature_unit = TEMP_CELSIUS + _attr_target_temperature_step = 1 + _attr_supported_features = SUPPORT_FLAGS + _attr_hvac_modes = [HVAC_MODE_HEAT, HVAC_MODE_OFF] + def __init__(self, heater, store): """Initialize the thermostat.""" self._heater = heater self._store = store - self._data = {} - - @property - def unique_id(self): - """Return a unique ID.""" - return self._heater.device_id - - @property - def name(self): - """Return the name of the entity.""" - return self._heater.name - - @property - def device_info(self): - """Return the device info.""" - return { + self._attr_unique_id = heater.device_id + self._attr_name = heater.name + self._attr_device_info = { "identifiers": {(DOMAIN, self.unique_id)}, "name": self.name, "manufacturer": "Ambiclimate", } + self._attr_min_temp = heater.get_min_temp() + self._attr_max_temp = heater.get_max_temp() - @property - def temperature_unit(self): - """Return the unit of measurement which this thermostat uses.""" - return TEMP_CELSIUS - - @property - def target_temperature(self): - """Return the target temperature.""" - return self._data.get("target_temperature") - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._data.get("temperature") - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._data.get("humidity") - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._heater.get_min_temp() - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._heater.get_max_temp() - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS - - @property - def hvac_modes(self): - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT, HVAC_MODE_OFF] - - @property - def hvac_mode(self): - """Return current operation.""" - if self._data.get("power", "").lower() == "on": - return HVAC_MODE_HEAT - - return HVAC_MODE_OFF - - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return await self._heater.set_target_temperature(temperature) - async def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode == HVAC_MODE_HEAT: await self._heater.turn_on() @@ -230,7 +172,7 @@ class AmbiclimateEntity(ClimateEntity): if hvac_mode == HVAC_MODE_OFF: await self._heater.turn_off() - async def async_update(self): + async def async_update(self) -> None: """Retrieve latest state.""" try: token_info = await self._heater.control.refresh_access_token() @@ -241,4 +183,10 @@ class AmbiclimateEntity(ClimateEntity): if token_info: await self._store.async_save(token_info) - self._data = await self._heater.update_device() + data = await self._heater.update_device() + 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 = ( + HVAC_MODE_HEAT if data.get("power", "").lower() == "on" else HVAC_MODE_OFF + ) diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py index 7ef0c5439aa..2643b01185a 100644 --- a/homeassistant/components/ambiclimate/config_flow.py +++ b/homeassistant/components/ambiclimate/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Ambiclimate.""" import logging +from aiohttp import web import ambiclimate from homeassistant import config_entries @@ -139,7 +140,7 @@ class AmbiclimateAuthCallbackView(HomeAssistantView): url = AUTH_CALLBACK_PATH name = AUTH_CALLBACK_NAME - async def get(self, request): + async def get(self, request: web.Request) -> str: """Receive authorization token.""" code = request.query.get("code") if code is None: diff --git a/homeassistant/components/ambiclimate/translations/de.json b/homeassistant/components/ambiclimate/translations/de.json index d91fc15f37d..3f4537a5d5c 100644 --- a/homeassistant/components/ambiclimate/translations/de.json +++ b/homeassistant/components/ambiclimate/translations/de.json @@ -6,7 +6,7 @@ "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen." }, "create_entry": { - "default": "Erfolgreiche Authentifizierung mit Ambiclimate" + "default": "Erfolgreich authentifiziert" }, "error": { "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst", diff --git a/homeassistant/components/ambiclimate/translations/he.json b/homeassistant/components/ambiclimate/translations/he.json index 7b7ec9c8c30..dc9f86871a7 100644 --- a/homeassistant/components/ambiclimate/translations/he.json +++ b/homeassistant/components/ambiclimate/translations/he.json @@ -1,11 +1,22 @@ { "config": { "abort": { + "access_token": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4.", "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3." }, "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" + }, + "error": { + "follow_link": "\u05d9\u05e9 \u05dc\u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d5\u05dc\u05d0\u05de\u05ea \u05d0\u05d5\u05ea\u05d5 \u05dc\u05e4\u05e0\u05d9 \u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05e9\u05dc\u05d7", + "no_token": "\u05dc\u05d0 \u05de\u05d0\u05d5\u05de\u05ea \u05e2\u05dd Ambiclimate" + }, + "step": { + "auth": { + "description": "\u05e0\u05d0 \u05dc\u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8\u05d9 [\u05e7\u05d9\u05e9\u05d5\u05e8]({authorization_url}) **\u05d5\u05dc\u05d0\u05e4\u05e9\u05e8** \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d7\u05e9\u05d1\u05d5\u05df \u05d4-Ambiclimate \u05e9\u05dc\u05da, \u05d5\u05dc\u05d0\u05d7\u05e8 \u05de\u05db\u05df \u05dc\u05d7\u05d6\u05d5\u05e8 \u05d5\u05dc\u05dc\u05d7\u05d5\u05e5 \u05e2\u05dc **\u05e9\u05dc\u05d7** \u05dc\u05de\u05d8\u05d4.\n(\u05e0\u05d0 \u05dc\u05d5\u05d5\u05d3\u05d0 \u05e9\u05d4\u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d4\u05ea\u05e7\u05e9\u05e8\u05d5\u05ea \u05d7\u05d5\u05d6\u05e8\u05ea \u05d4\u05d5\u05d0 {cb_url})", + "title": "\u05d0\u05de\u05ea \u05d0\u05ea Ambiclimate" + } } } } \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 9036a4d89a2..d719f9b3728 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,15 +1,15 @@ """Support for Ambient Weather Station Service.""" +from __future__ import annotations from aioambient import Client from aioambient.errors import WebsocketError -import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DOMAIN as BINARY_SENSOR, ) from homeassistant.components.sensor import DOMAIN as SENSOR -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, @@ -28,11 +28,13 @@ from homeassistant.const import ( IRRADIATION_WATTS_PER_SQUARE_METER, LIGHT_LUX, PERCENTAGE, + PRECIPITATION_INCHES, + PRECIPITATION_INCHES_PER_HOUR, PRESSURE_INHG, SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) -from homeassistant.core import callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -156,7 +158,7 @@ TYPE_WINDSPDMPH_AVG2M = "windspdmph_avg2m" TYPE_WINDSPEEDMPH = "windspeedmph" TYPE_YEARLYRAININ = "yearlyrainin" SENSOR_TYPES = { - TYPE_24HOURRAININ: ("24 Hr Rain", "in", SENSOR, None), + TYPE_24HOURRAININ: ("24 Hr Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_BAROMABSIN: ("Abs Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), TYPE_BAROMRELIN: ("Rel Pressure", PRESSURE_INHG, SENSOR, DEVICE_CLASS_PRESSURE), TYPE_BATT10: ("Battery 10", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), @@ -172,11 +174,16 @@ SENSOR_TYPES = { TYPE_BATTOUT: ("Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), TYPE_BATT_CO2: ("CO2 Battery", None, BINARY_SENSOR, DEVICE_CLASS_BATTERY), TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, SENSOR, DEVICE_CLASS_CO2), - TYPE_DAILYRAININ: ("Daily Rain", "in", SENSOR, None), + TYPE_DAILYRAININ: ("Daily Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_DEWPOINT: ("Dew Point", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_EVENTRAININ: ("Event Rain", "in", SENSOR, None), + TYPE_EVENTRAININ: ("Event Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_FEELSLIKE: ("Feels Like", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", SENSOR, None), + TYPE_HOURLYRAININ: ( + "Hourly Rain Rate", + PRECIPITATION_INCHES_PER_HOUR, + SENSOR, + None, + ), TYPE_HUMIDITY10: ("Humidity 10", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_HUMIDITY1: ("Humidity 1", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_HUMIDITY2: ("Humidity 2", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), @@ -191,7 +198,7 @@ SENSOR_TYPES = { TYPE_HUMIDITYIN: ("Humidity In", PERCENTAGE, SENSOR, DEVICE_CLASS_HUMIDITY), TYPE_LASTRAIN: ("Last Rain", None, SENSOR, DEVICE_CLASS_TIMESTAMP), TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_MONTHLYRAININ: ("Monthly Rain", "in", SENSOR, None), + TYPE_MONTHLYRAININ: ("Monthly Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_PM25_24H: ( "PM25 24h Avg", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -277,9 +284,9 @@ SENSOR_TYPES = { TYPE_TEMP9F: ("Temp 9", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), TYPE_TEMPF: ("Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), TYPE_TEMPINF: ("Inside Temp", TEMP_FAHRENHEIT, SENSOR, DEVICE_CLASS_TEMPERATURE), - TYPE_TOTALRAININ: ("Lifetime Rain", "in", SENSOR, None), + TYPE_TOTALRAININ: ("Lifetime Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_UV: ("uv", "Index", SENSOR, None), - TYPE_WEEKLYRAININ: ("Weekly Rain", "in", SENSOR, None), + TYPE_WEEKLYRAININ: ("Weekly Rain", PRECIPITATION_INCHES, SENSOR, None), TYPE_WINDDIR: ("Wind Dir", DEGREE, SENSOR, None), TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", DEGREE, SENSOR, None), TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), @@ -288,47 +295,16 @@ SENSOR_TYPES = { TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, SENSOR, None), TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, SENSOR, None), TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, SENSOR, None), - TYPE_YEARLYRAININ: ("Yearly Rain", "in", SENSOR, None), + TYPE_YEARLYRAININ: ("Yearly Rain", PRECIPITATION_INCHES, SENSOR, None), } -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_APP_KEY): cv.string, - vol.Required(CONF_API_KEY): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.deprecated(DOMAIN) -async def async_setup(hass, config): - """Set up the Ambient PWS integration.""" - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CLIENT] = {} - - if DOMAIN not in config: - return True - conf = config[DOMAIN] - - # Store config for use during entry setup: - hass.data[DOMAIN][DATA_CONFIG] = conf - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_API_KEY: conf[CONF_API_KEY], CONF_APP_KEY: conf[CONF_APP_KEY]}, - ) - ) - - return True - - -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Ambient PWS as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_CLIENT: {}}) + if not config_entry.unique_id: hass.config_entries.async_update_entry( config_entry, unique_id=config_entry.data[CONF_APP_KEY] @@ -351,7 +327,7 @@ async def async_setup_entry(hass, config_entry): LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - async def _async_disconnect_websocket(*_): + async def _async_disconnect_websocket(_: Event) -> None: await ambient.client.websocket.disconnect() config_entry.async_on_unload( @@ -363,7 +339,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload an Ambient PWS config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) hass.async_create_task(ambient.ws_disconnect()) @@ -371,7 +347,7 @@ async def async_unload_entry(hass, config_entry): return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" version = config_entry.version @@ -395,19 +371,21 @@ async def async_migrate_entry(hass, config_entry): class AmbientStation: """Define a class to handle the Ambient websocket.""" - def __init__(self, hass, config_entry, client): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, client: Client + ) -> None: """Initialize.""" self._config_entry = config_entry self._entry_setup_complete = False self._hass = hass self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY self.client = client - self.stations = {} + self.stations: dict[str, dict] = {} - async def _attempt_connect(self): + async def _attempt_connect(self) -> None: """Attempt to connect to the socket (retrying later on fail).""" - async def connect(timestamp=None): + async def connect(timestamp: int | None = None) -> None: """Connect.""" await self.client.websocket.connect() @@ -418,14 +396,14 @@ class AmbientStation: self._ws_reconnect_delay = min(2 * self._ws_reconnect_delay, 480) async_call_later(self._hass, self._ws_reconnect_delay, connect) - async def ws_connect(self): + async def ws_connect(self) -> None: """Register handlers and connect to the websocket.""" - def on_connect(): + def on_connect() -> None: """Define a handler to fire when the websocket is connected.""" LOGGER.info("Connected to websocket") - def on_data(data): + def on_data(data: dict) -> None: """Define a handler to fire when the data is received.""" mac_address = data["macAddress"] if data != self.stations[mac_address][ATTR_LAST_DATA]: @@ -435,11 +413,11 @@ class AmbientStation: self._hass, f"ambient_station_data_update_{mac_address}" ) - def on_disconnect(): + def on_disconnect() -> None: """Define a handler to fire when the websocket is disconnected.""" LOGGER.info("Disconnected from websocket") - def on_subscribed(data): + def on_subscribed(data: dict) -> None: """Define a handler to fire when the subscription is set.""" for station in data["devices"]: if station["macAddress"] in self.stations: @@ -480,7 +458,7 @@ class AmbientStation: await self._attempt_connect() - async def ws_disconnect(self): + async def ws_disconnect(self) -> None: """Disconnect from the websocket.""" await self.client.websocket.disconnect() @@ -489,73 +467,49 @@ class AmbientWeatherEntity(Entity): """Define a base Ambient PWS entity.""" def __init__( - self, ambient, mac_address, station_name, sensor_type, sensor_name, device_class - ): + self, + ambient: AmbientStation, + mac_address: str, + station_name: str, + sensor_type: str, + sensor_name: str, + device_class: str | None, + ) -> None: """Initialize the sensor.""" self._ambient = ambient - self._device_class = device_class - self._mac_address = mac_address - self._sensor_name = sensor_name - self._sensor_type = sensor_type - self._state = None - self._station_name = station_name - - @property - def available(self): - """Return True if entity is available.""" - # Since the solarradiation_lx sensor is created only if the - # user shows a solarradiation sensor, ensure that the - # solarradiation_lx sensor shows as available if the solarradiation - # sensor is available: - if self._sensor_type == TYPE_SOLARRADIATION_LX: - return ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - TYPE_SOLARRADIATION - ) - is not None - ) - return ( - self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) - is not None - ) - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def device_info(self): - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._mac_address)}, - "name": self._station_name, + self._attr_device_class = device_class + self._attr_device_info = { + "identifiers": {(DOMAIN, mac_address)}, + "name": station_name, "manufacturer": "Ambient Weather", } + self._attr_name = f"{station_name}_{sensor_name}" + self._attr_should_poll = False + self._attr_unique_id = f"{mac_address}_{sensor_type}" + self._mac_address = mac_address + self._sensor_type = sensor_type - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._station_name}_{self._sensor_name}" - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def unique_id(self): - """Return a unique, unchanging string that represents this sensor.""" - return f"{self._mac_address}_{self._sensor_type}" - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" + if self._sensor_type == TYPE_SOLARRADIATION_LX: + self._attr_available = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + TYPE_SOLARRADIATION + ) + is not None + ) + else: + self._attr_available = ( + self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) + is not None + ) + self.update_from_latest_data() self.async_write_ha_state() @@ -568,6 +522,6 @@ class AmbientWeatherEntity(Entity): self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the entity from the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index c2e5ad8b4f4..093a582791e 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,10 +1,14 @@ """Support for Ambient Weather Station binary sensors.""" +from __future__ import annotations + from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( SENSOR_TYPES, @@ -27,7 +31,9 @@ from . import ( from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ambient PWS binary sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] @@ -47,15 +53,19 @@ async def async_setup_entry(hass, entry, async_add_entities): ) ) - async_add_entities(binary_sensor_list, True) + async_add_entities(binary_sensor_list) class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): """Define an Ambient binary sensor.""" - @property - def is_on(self): - """Return the status of the sensor.""" + @callback + def update_from_latest_data(self) -> None: + """Fetch new state data for the entity.""" + state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( + self._sensor_type + ) + if self._sensor_type in ( TYPE_BATT1, TYPE_BATT10, @@ -72,13 +82,6 @@ class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorEntity): TYPE_PM25_BATT, TYPE_PM25IN_BATT, ): - return self._state == 0 - - return self._state == 1 - - @callback - def update_from_latest_data(self): - """Fetch new state data for the entity.""" - self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) + self._attr_is_on = state == 0 + else: + self._attr_is_on = state == 1 diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index 429388dcaba..d93d502ac92 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -1,10 +1,13 @@ """Config flow to configure the Ambient PWS component.""" +from __future__ import annotations + from aioambient import Client from aioambient.errors import AmbientError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_APP_KEY, DOMAIN @@ -15,13 +18,13 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" self.data_schema = vol.Schema( {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} ) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", @@ -29,11 +32,7 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) - - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return await self._show_form() diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 6d4c40d260d..42b22d26a10 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.2.4"], + "requirements": ["aioambient==1.2.5"], "codeowners": ["@bachya"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 7c60d1da9bc..a606b401bc0 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,18 +1,25 @@ """Support for Ambient Weather Station sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import DOMAIN as SENSOR, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( SENSOR_TYPES, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX, + AmbientStation, AmbientWeatherEntity, ) from .const import ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, DATA_CLIENT, DOMAIN -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ambient PWS sensors based on a config entry.""" ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] @@ -33,7 +40,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ) ) - async_add_entities(sensor_list, True) + async_add_entities(sensor_list) class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): @@ -41,33 +48,23 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): def __init__( self, - ambient, - mac_address, - station_name, - sensor_type, - sensor_name, - device_class, - unit, - ): + ambient: AmbientStation, + mac_address: str, + station_name: str, + sensor_type: str, + sensor_name: str, + device_class: str | None, + unit: str | None, + ) -> None: """Initialize the sensor.""" super().__init__( ambient, mac_address, station_name, sensor_type, sensor_name, device_class ) - self._unit = unit - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._attr_unit_of_measurement = unit @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" if self._sensor_type == TYPE_SOLARRADIATION_LX: # If the user requests the solarradiation_lx sensor, use the @@ -78,10 +75,10 @@ class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity): ].get(TYPE_SOLARRADIATION) if w_m2_brightness_val is None: - self._state = None + self._attr_state = None else: - self._state = round(float(w_m2_brightness_val) / 0.0079) + self._attr_state = round(float(w_m2_brightness_val) / 0.0079) else: - self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( - self._sensor_type - ) + self._attr_state = self._ambient.stations[self._mac_address][ + ATTR_LAST_DATA + ].get(self._sensor_type) diff --git a/homeassistant/components/ambient_station/translations/de.json b/homeassistant/components/ambient_station/translations/de.json index c6570fee0e3..8dda644cc26 100644 --- a/homeassistant/components/ambient_station/translations/de.json +++ b/homeassistant/components/ambient_station/translations/de.json @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Schl\u00fcssel", + "api_key": "API-Schl\u00fcssel", "app_key": "Anwendungsschl\u00fcssel" }, "title": "Gib deine Informationen ein" diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 571ffd90f22..37aff988162 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -8,6 +8,10 @@ import async_timeout from homeassistant.components import hassio from homeassistant.components.api import ATTR_INSTALLATION_TYPE from homeassistant.components.automation.const import DOMAIN as AUTOMATION_DOMAIN +from homeassistant.components.energy import ( + DOMAIN as ENERGY_DOMAIN, + is_configured as energy_is_configured, +) from homeassistant.const import ATTR_DOMAIN, __version__ as HA_VERSION from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -26,8 +30,10 @@ from .const import ( ATTR_AUTOMATION_COUNT, ATTR_BASE, ATTR_BOARD, + ATTR_CONFIGURED, ATTR_CUSTOM_INTEGRATIONS, ATTR_DIAGNOSTICS, + ATTR_ENERGY, ATTR_HEALTHY, ATTR_INTEGRATION_COUNT, ATTR_INTEGRATIONS, @@ -171,10 +177,10 @@ class Analytics: ATTR_STATISTICS, False ): configured_integrations = await asyncio.gather( - *[ + *( async_get_integration(self.hass, domain) for domain in async_get_loaded_integrations(self.hass) - ], + ), return_exceptions=True, ) @@ -201,10 +207,10 @@ class Analytics: if supervisor_info is not None: installed_addons = await asyncio.gather( - *[ + *( hassio.async_get_addon_info(self.hass, addon[ATTR_SLUG]) for addon in supervisor_info[ATTR_ADDONS] - ] + ) ) for addon in installed_addons: addons.append( @@ -222,6 +228,11 @@ class Analytics: if supervisor_info is not None: payload[ATTR_ADDONS] = addons + if ENERGY_DOMAIN in integrations: + payload[ATTR_ENERGY] = { + ATTR_CONFIGURED: await energy_is_configured(self.hass) + } + if self.preferences.get(ATTR_STATISTICS, False): payload[ATTR_STATE_COUNT] = len(self.hass.states.async_all()) payload[ATTR_AUTOMATION_COUNT] = len( diff --git a/homeassistant/components/analytics/const.py b/homeassistant/components/analytics/const.py index 4688c578a00..8576e22073f 100644 --- a/homeassistant/components/analytics/const.py +++ b/homeassistant/components/analytics/const.py @@ -21,8 +21,10 @@ ATTR_AUTO_UPDATE = "auto_update" ATTR_AUTOMATION_COUNT = "automation_count" ATTR_BASE = "base" ATTR_BOARD = "board" +ATTR_CONFIGURED = "configured" ATTR_CUSTOM_INTEGRATIONS = "custom_integrations" ATTR_DIAGNOSTICS = "diagnostics" +ATTR_ENERGY = "energy" ATTR_HEALTHY = "healthy" ATTR_INSTALLATION_TYPE = "installation_type" ATTR_INTEGRATION_COUNT = "integration_count" diff --git a/homeassistant/components/analytics/manifest.json b/homeassistant/components/analytics/manifest.json index 49edf1bcf8c..2dae8d4e629 100644 --- a/homeassistant/components/analytics/manifest.json +++ b/homeassistant/components/analytics/manifest.json @@ -2,8 +2,17 @@ "domain": "analytics", "name": "Analytics", "documentation": "https://www.home-assistant.io/integrations/analytics", - "codeowners": ["@home-assistant/core", "@ludeeus"], - "dependencies": ["api", "websocket_api"], + "codeowners": [ + "@home-assistant/core", + "@ludeeus" + ], + "dependencies": [ + "api", + "websocket_api" + ], + "after_dependencies": [ + "energy" + ], "quality_scale": "internal", "iot_class": "cloud_push" -} +} \ No newline at end of file diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 5db73d14914..98d1ac0ae18 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -80,7 +80,9 @@ SUPPORT_FIRETV = ( | SUPPORT_STOP ) +ATTR_ADB_RESPONSE = "adb_response" ATTR_DEVICE_PATH = "device_path" +ATTR_HDMI_INPUT = "hdmi_input" ATTR_LOCAL_PATH = "local_path" CONF_ADBKEY = "adbkey" @@ -374,13 +376,13 @@ def adb_decorator(override_available=False): err, ) await self.aftv.adb_close() - self._available = False + 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() - self._available = False + self._attr_available = False raise return _adb_exception_catcher @@ -404,7 +406,7 @@ class ADBDevice(MediaPlayerEntity): ): """Initialize the Android TV / Fire TV device.""" self.aftv = aftv - self._name = name + self._attr_name = name self._app_id_to_name = APPS.copy() self._app_id_to_name.update(apps) self._app_name_to_id = { @@ -415,12 +417,8 @@ class ADBDevice(MediaPlayerEntity): # in `self._app_name_to_id` for key, value in apps.items(): self._app_name_to_id[value] = key - self._get_sources = get_sources - self._keys = KEYS - - self._device_properties = self.aftv.device_properties - self._unique_id = self._device_properties.get("serialno") + self._attr_unique_id = self.aftv.device_properties.get("serialno") self.turn_on_command = turn_on_command self.turn_off_command = turn_off_command @@ -446,66 +444,11 @@ class ADBDevice(MediaPlayerEntity): self.exceptions = (ConnectionResetError, RuntimeError) # Property attributes - self._adb_response = None - self._available = True - self._current_app = None - self._sources = None - self._state = None - self._hdmi_input = None - - @property - def app_id(self): - """Return the current app.""" - return self._current_app - - @property - def app_name(self): - """Return the friendly name of the current app.""" - return self._app_id_to_name.get(self._current_app, self._current_app) - - @property - def available(self): - """Return whether or not the ADB connection is valid.""" - return self._available - - @property - def extra_state_attributes(self): - """Provide the last ADB command's response and the device's HDMI input as attributes.""" - return { - "adb_response": self._adb_response, - "hdmi_input": self._hdmi_input, + self._attr_extra_state_attributes = { + ATTR_ADB_RESPONSE: None, + ATTR_HDMI_INPUT: None, } - @property - def media_image_hash(self): - """Hash value for media image.""" - return f"{datetime.now().timestamp()}" if self._screencap else None - - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def source(self): - """Return the current app.""" - return self._app_id_to_name.get(self._current_app, self._current_app) - - @property - def source_list(self): - """Return a list of running apps.""" - return self._sources - - @property - def state(self): - """Return the state of the player.""" - return self._state - - @property - def unique_id(self): - """Return the device unique id.""" - return self._unique_id - @adb_decorator() async def _adb_screencap(self): """Take a screen capture from the device.""" @@ -515,6 +458,9 @@ class ADBDevice(MediaPlayerEntity): """Fetch current playing image.""" if not self._screencap or self.state in [STATE_OFF, None] or not self.available: return None, None + self._attr_media_image_hash = ( + f"{datetime.now().timestamp()}" if self._screencap else None + ) media_data = await self._adb_screencap() if media_data: @@ -584,15 +530,17 @@ class ADBDevice(MediaPlayerEntity): @adb_decorator() async def adb_command(self, cmd): """Send an ADB command to an Android TV / Fire TV device.""" - key = self._keys.get(cmd) + key = KEYS.get(cmd) if key: await self.aftv.adb_shell(f"input keyevent {key}") return if cmd == "GET_PROPERTIES": - self._adb_response = str(await self.aftv.get_properties_dict()) + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = str( + await self.aftv.get_properties_dict() + ) self.async_write_ha_state() - return self._adb_response + return try: response = await self.aftv.adb_shell(cmd) @@ -600,17 +548,17 @@ class ADBDevice(MediaPlayerEntity): return if isinstance(response, str) and response.strip(): - self._adb_response = response.strip() + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = response.strip() self.async_write_ha_state() - return self._adb_response + return @adb_decorator() async def learn_sendevent(self): """Translate a key press on a remote to ADB 'sendevent' commands.""" output = await self.aftv.learn_sendevent() if output: - self._adb_response = output + self._attr_extra_state_attributes[ATTR_ADB_RESPONSE] = output self.async_write_ha_state() msg = f"Output from service '{SERVICE_LEARN_SENDEVENT}' from {self.entity_id}: '{output}'" @@ -634,84 +582,48 @@ class ADBDevice(MediaPlayerEntity): class AndroidTVDevice(ADBDevice): """Representation of an Android TV device.""" - def __init__( - self, - aftv, - name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, - ): - """Initialize the Android TV device.""" - super().__init__( - aftv, - name, - apps, - get_sources, - turn_on_command, - turn_off_command, - exclude_unnamed_apps, - screencap, - ) - - self._is_volume_muted = None - self._volume_level = None + _attr_supported_features = SUPPORT_ANDROIDTV @adb_decorator(override_available=True) async def async_update(self): """Update the device state and, if necessary, re-connect.""" # Check if device is disconnected. - if not self._available: + if not self.available: # Try to connect - self._available = await self.aftv.adb_connect(always_log_errors=False) + self._attr_available = await self.aftv.adb_connect(always_log_errors=False) # If the ADB connection is not intact, don't update. - if not self._available: + if not self.available: return # Get the updated state and attributes. ( state, - self._current_app, + self._attr_app_id, running_apps, _, - self._is_volume_muted, - self._volume_level, - self._hdmi_input, + self._attr_is_volume_muted, + self._attr_volume_level, + self._attr_extra_state_attributes[ATTR_HDMI_INPUT], ) = await self.aftv.update(self._get_sources) - self._state = ANDROIDTV_STATES.get(state) - if self._state is None: - self._available = False + self._attr_state = ANDROIDTV_STATES.get(state) + if self._attr_state is None: + self._attr_available = False if running_apps: + self._attr_source = self._attr_app_name = self._app_id_to_name.get( + self._attr_app_id, self._attr_app_id + ) sources = [ self._app_id_to_name.get( app_id, app_id if not self._exclude_unnamed_apps else None ) for app_id in running_apps ] - self._sources = [source for source in sources if source] + self._attr_source_list = [source for source in sources if source] else: - self._sources = None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._is_volume_muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ANDROIDTV - - @property - def volume_level(self): - """Return the volume level.""" - return self._volume_level + self._attr_source_list = None @adb_decorator() async def async_media_stop(self): @@ -731,56 +643,56 @@ class AndroidTVDevice(ADBDevice): @adb_decorator() async def async_volume_down(self): """Send volume down command.""" - self._volume_level = await self.aftv.volume_down(self._volume_level) + self._attr_volume_level = await self.aftv.volume_down(self._attr_volume_level) @adb_decorator() async def async_volume_up(self): """Send volume up command.""" - self._volume_level = await self.aftv.volume_up(self._volume_level) + self._attr_volume_level = await self.aftv.volume_up(self._attr_volume_level) class FireTVDevice(ADBDevice): """Representation of a Fire TV device.""" + _attr_supported_features = SUPPORT_FIRETV + @adb_decorator(override_available=True) async def async_update(self): """Update the device state and, if necessary, re-connect.""" # Check if device is disconnected. - if not self._available: + if not self.available: # Try to connect - self._available = await self.aftv.adb_connect(always_log_errors=False) + self._attr_available = await self.aftv.adb_connect(always_log_errors=False) # If the ADB connection is not intact, don't update. - if not self._available: + if not self.available: return # Get the `state`, `current_app`, `running_apps` and `hdmi_input`. ( state, - self._current_app, + self._attr_app_id, running_apps, - self._hdmi_input, + self._attr_extra_state_attributes[ATTR_HDMI_INPUT], ) = await self.aftv.update(self._get_sources) - self._state = ANDROIDTV_STATES.get(state) - if self._state is None: - self._available = False + self._attr_state = ANDROIDTV_STATES.get(state) + if self._attr_state is None: + self._attr_available = False if running_apps: + self._attr_source = self._app_id_to_name.get( + self._attr_app_id, self._attr_app_id + ) sources = [ self._app_id_to_name.get( app_id, app_id if not self._exclude_unnamed_apps else None ) for app_id in running_apps ] - self._sources = [source for source in sources if source] + self._attr_source_list = [source for source in sources if source] else: - self._sources = None - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_FIRETV + self._attr_source_list = None @adb_decorator() async def async_media_stop(self): diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 0669a3bb6c6..2f4ce0ee7db 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -65,25 +65,13 @@ class PwrCtrlSwitch(SwitchEntity): """Initialize the PwrCtrl switch.""" self._port = port self._parent_device = parent_device - - @property - def unique_id(self): - """Return the unique ID of the device.""" - return f"{self._port.device.host}-{self._port.get_index()}" - - @property - def name(self): - """Return the name of the device.""" - return self._port.label - - @property - def is_on(self): - """Return true if the device is on.""" - return self._port.get_state() + self._attr_unique_id = f"{port.device.host}-{port.get_index()}" + self._attr_name = port.label def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() + self._attr_is_on = self._port.get_state() def turn_on(self, **kwargs): """Turn the switch on.""" diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 788fa8db7eb..18b2e704538 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -82,11 +82,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AnthemAVR(MediaPlayerEntity): """Entity reading values from Anthem AVR protocol.""" + _attr_should_poll = False + _attr_supported_features = SUPPORT_ANTHEMAV + def __init__(self, avr, name): """Initialize entity with transport.""" super().__init__() self.avr = avr - self._name = name + self._attr_name = name or self._lookup("model") def _lookup(self, propname, dval=None): return getattr(self.avr.protocol, propname, dval) @@ -97,21 +100,6 @@ class AnthemAVR(MediaPlayerEntity): async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ANTHEMAV - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return name of device.""" - return self._name or self._lookup("model") - @property def state(self): """Return state of power on/off.""" diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index daf9592f3e6..8a1f98329bb 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -25,20 +25,9 @@ class OnlineStatus(BinarySensorEntity): def __init__(self, config, data): """Initialize the APCUPSd binary device.""" - self._config = config self._data = data - self._state = None - - @property - def name(self): - """Return the name of the UPS online status sensor.""" - return self._config[CONF_NAME] - - @property - def is_on(self): - """Return true if the UPS is online, else false.""" - return self._state & VALUE_ONLINE > 0 + self._attr_name = config[CONF_NAME] def update(self): """Get the status report from APCUPSd and set this entity's state.""" - self._state = int(self._data.status[KEY_STATUS], 16) + self._attr_is_on = int(self._data.status[KEY_STATUS], 16) & VALUE_ONLINE > 0 diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 36dc1155b7f..d30625ee793 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -7,15 +7,16 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_RESOURCES, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_HERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, TEMP_CELSIUS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) import homeassistant.helpers.config_validation as cv @@ -25,72 +26,72 @@ _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = "UPS " SENSOR_TYPES = { - "alarmdel": ["Alarm Delay", "", "mdi:alarm"], - "ambtemp": ["Ambient Temperature", "", "mdi:thermometer"], - "apc": ["Status Data", "", "mdi:information-outline"], - "apcmodel": ["Model", "", "mdi:information-outline"], - "badbatts": ["Bad Batteries", "", "mdi:information-outline"], - "battdate": ["Battery Replaced", "", "mdi:calendar-clock"], - "battstat": ["Battery Status", "", "mdi:information-outline"], - "battv": ["Battery Voltage", VOLT, "mdi:flash"], - "bcharge": ["Battery", PERCENTAGE, "mdi:battery"], - "cable": ["Cable Type", "", "mdi:ethernet-cable"], - "cumonbatt": ["Total Time on Battery", "", "mdi:timer-outline"], - "date": ["Status Date", "", "mdi:calendar-clock"], - "dipsw": ["Dip Switch Settings", "", "mdi:information-outline"], - "dlowbatt": ["Low Battery Signal", "", "mdi:clock-alert"], - "driver": ["Driver", "", "mdi:information-outline"], - "dshutd": ["Shutdown Delay", "", "mdi:timer-outline"], - "dwake": ["Wake Delay", "", "mdi:timer-outline"], - "endapc": ["Date and Time", "", "mdi:calendar-clock"], - "extbatts": ["External Batteries", "", "mdi:information-outline"], - "firmware": ["Firmware Version", "", "mdi:information-outline"], - "hitrans": ["Transfer High", VOLT, "mdi:flash"], - "hostname": ["Hostname", "", "mdi:information-outline"], - "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent"], - "itemp": ["Internal Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "lastxfer": ["Last Transfer", "", "mdi:transfer"], - "linefail": ["Input Voltage Status", "", "mdi:information-outline"], - "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline"], - "linev": ["Input Voltage", VOLT, "mdi:flash"], - "loadpct": ["Load", PERCENTAGE, "mdi:gauge"], - "loadapnt": ["Load Apparent Power", PERCENTAGE, "mdi:gauge"], - "lotrans": ["Transfer Low", VOLT, "mdi:flash"], - "mandate": ["Manufacture Date", "", "mdi:calendar"], - "masterupd": ["Master Update", "", "mdi:information-outline"], - "maxlinev": ["Input Voltage High", VOLT, "mdi:flash"], - "maxtime": ["Battery Timeout", "", "mdi:timer-off-outline"], - "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert"], - "minlinev": ["Input Voltage Low", VOLT, "mdi:flash"], - "mintimel": ["Shutdown Time", "", "mdi:timer-outline"], - "model": ["Model", "", "mdi:information-outline"], - "nombattv": ["Battery Nominal Voltage", VOLT, "mdi:flash"], - "nominv": ["Nominal Input Voltage", VOLT, "mdi:flash"], - "nomoutv": ["Nominal Output Voltage", VOLT, "mdi:flash"], - "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash"], - "nomapnt": ["Nominal Apparent Power", ELECTRICAL_VOLT_AMPERE, "mdi:flash"], - "numxfers": ["Transfer Count", "", "mdi:counter"], - "outcurnt": ["Output Current", ELECTRICAL_CURRENT_AMPERE, "mdi:flash"], - "outputv": ["Output Voltage", VOLT, "mdi:flash"], - "reg1": ["Register 1 Fault", "", "mdi:information-outline"], - "reg2": ["Register 2 Fault", "", "mdi:information-outline"], - "reg3": ["Register 3 Fault", "", "mdi:information-outline"], - "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert"], - "selftest": ["Last Self Test", "", "mdi:calendar-clock"], - "sense": ["Sensitivity", "", "mdi:information-outline"], - "serialno": ["Serial Number", "", "mdi:information-outline"], - "starttime": ["Startup Time", "", "mdi:calendar-clock"], - "statflag": ["Status Flag", "", "mdi:information-outline"], - "status": ["Status", "", "mdi:information-outline"], - "stesti": ["Self Test Interval", "", "mdi:information-outline"], - "timeleft": ["Time Left", "", "mdi:clock-alert"], - "tonbatt": ["Time on Battery", "", "mdi:timer-outline"], - "upsmode": ["Mode", "", "mdi:information-outline"], - "upsname": ["Name", "", "mdi:information-outline"], - "version": ["Daemon Info", "", "mdi:information-outline"], - "xoffbat": ["Transfer from Battery", "", "mdi:transfer"], - "xoffbatt": ["Transfer from Battery", "", "mdi:transfer"], - "xonbatt": ["Transfer to Battery", "", "mdi:transfer"], + "alarmdel": ["Alarm Delay", "", "mdi:alarm", None], + "ambtemp": ["Ambient Temperature", "", "mdi:thermometer", None], + "apc": ["Status Data", "", "mdi:information-outline", None], + "apcmodel": ["Model", "", "mdi:information-outline", None], + "badbatts": ["Bad Batteries", "", "mdi:information-outline", None], + "battdate": ["Battery Replaced", "", "mdi:calendar-clock", None], + "battstat": ["Battery Status", "", "mdi:information-outline", None], + "battv": ["Battery Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "bcharge": ["Battery", PERCENTAGE, "mdi:battery", None], + "cable": ["Cable Type", "", "mdi:ethernet-cable", None], + "cumonbatt": ["Total Time on Battery", "", "mdi:timer-outline", None], + "date": ["Status Date", "", "mdi:calendar-clock", None], + "dipsw": ["Dip Switch Settings", "", "mdi:information-outline", None], + "dlowbatt": ["Low Battery Signal", "", "mdi:clock-alert", None], + "driver": ["Driver", "", "mdi:information-outline", None], + "dshutd": ["Shutdown Delay", "", "mdi:timer-outline", None], + "dwake": ["Wake Delay", "", "mdi:timer-outline", None], + "endapc": ["Date and Time", "", "mdi:calendar-clock", None], + "extbatts": ["External Batteries", "", "mdi:information-outline", None], + "firmware": ["Firmware Version", "", "mdi:information-outline", None], + "hitrans": ["Transfer High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "hostname": ["Hostname", "", "mdi:information-outline", None], + "humidity": ["Ambient Humidity", PERCENTAGE, "mdi:water-percent", None], + "itemp": ["Internal Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + "lastxfer": ["Last Transfer", "", "mdi:transfer", None], + "linefail": ["Input Voltage Status", "", "mdi:information-outline", None], + "linefreq": ["Line Frequency", FREQUENCY_HERTZ, "mdi:information-outline", None], + "linev": ["Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "loadpct": ["Load", PERCENTAGE, "mdi:gauge", None], + "loadapnt": ["Load Apparent Power", PERCENTAGE, "mdi:gauge", None], + "lotrans": ["Transfer Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "mandate": ["Manufacture Date", "", "mdi:calendar", None], + "masterupd": ["Master Update", "", "mdi:information-outline", None], + "maxlinev": ["Input Voltage High", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "maxtime": ["Battery Timeout", "", "mdi:timer-off-outline", None], + "mbattchg": ["Battery Shutdown", PERCENTAGE, "mdi:battery-alert", None], + "minlinev": ["Input Voltage Low", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "mintimel": ["Shutdown Time", "", "mdi:timer-outline", None], + "model": ["Model", "", "mdi:information-outline", None], + "nombattv": ["Battery Nominal Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "nominv": ["Nominal Input Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "nomoutv": ["Nominal Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "nompower": ["Nominal Output Power", POWER_WATT, "mdi:flash", None], + "nomapnt": ["Nominal Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], + "numxfers": ["Transfer Count", "", "mdi:counter", None], + "outcurnt": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], + "outputv": ["Output Voltage", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "reg1": ["Register 1 Fault", "", "mdi:information-outline", None], + "reg2": ["Register 2 Fault", "", "mdi:information-outline", None], + "reg3": ["Register 3 Fault", "", "mdi:information-outline", None], + "retpct": ["Restore Requirement", PERCENTAGE, "mdi:battery-alert", None], + "selftest": ["Last Self Test", "", "mdi:calendar-clock", None], + "sense": ["Sensitivity", "", "mdi:information-outline", None], + "serialno": ["Serial Number", "", "mdi:information-outline", None], + "starttime": ["Startup Time", "", "mdi:calendar-clock", None], + "statflag": ["Status Flag", "", "mdi:information-outline", None], + "status": ["Status", "", "mdi:information-outline", None], + "stesti": ["Self Test Interval", "", "mdi:information-outline", None], + "timeleft": ["Time Left", "", "mdi:clock-alert", None], + "tonbatt": ["Time on Battery", "", "mdi:timer-outline", None], + "upsmode": ["Mode", "", "mdi:information-outline", None], + "upsname": ["Name", "", "mdi:information-outline", None], + "version": ["Daemon Info", "", "mdi:information-outline", None], + "xoffbat": ["Transfer from Battery", "", "mdi:transfer", None], + "xoffbatt": ["Transfer from Battery", "", "mdi:transfer", None], + "xonbatt": ["Transfer to Battery", "", "mdi:transfer", None], } SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} @@ -98,9 +99,9 @@ INFERRED_UNITS = { " Minutes": TIME_MINUTES, " Seconds": TIME_SECONDS, " Percent": PERCENTAGE, - " Volts": VOLT, - " Ampere": ELECTRICAL_CURRENT_AMPERE, - " Volt-Ampere": ELECTRICAL_VOLT_AMPERE, + " Volts": ELECTRIC_POTENTIAL_VOLT, + " Ampere": ELECTRIC_CURRENT_AMPERE, + " Volt-Ampere": POWER_VOLT_AMPERE, " Watts": POWER_WATT, " Hz": FREQUENCY_HERTZ, " C": TEMP_CELSIUS, @@ -162,39 +163,19 @@ class APCUPSdSensor(SensorEntity): """Initialize the sensor.""" self._data = data self.type = sensor_type - self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] - self._unit = SENSOR_TYPES[sensor_type][1] - self._inferred_unit = None - self._state = None - - @property - def name(self): - """Return the name of the UPS sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] - - @property - def state(self): - """Return true if the UPS is online, else False.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - if not self._unit: - return self._inferred_unit - return self._unit + self._attr_name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0] + self._attr_icon = SENSOR_TYPES[self.type][2] + if SENSOR_TYPES[sensor_type][1]: + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][3] def update(self): """Get the latest status and use it to update our sensor state.""" if self.type.upper() not in self._data.status: - self._state = None - self._inferred_unit = None + self._attr_state = None else: - self._state, self._inferred_unit = infer_unit( + self._attr_state, inferred_unit = infer_unit( self._data.status[self.type.upper()] ) + if not self._attr_unit_of_measurement: + self._attr_unit_of_measurement = inferred_unit diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a91d8540286..0a11cf04651 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -43,6 +43,7 @@ from homeassistant.helpers.system_info import async_get_system_info _LOGGER = logging.getLogger(__name__) ATTR_BASE_URL = "base_url" +ATTR_CURRENCY = "currency" ATTR_EXTERNAL_URL = "external_url" ATTR_INTERNAL_URL = "internal_url" ATTR_LOCATION_NAME = "location_name" @@ -195,6 +196,7 @@ class APIDiscoveryView(HomeAssistantView): # always needs authentication ATTR_REQUIRES_API_PASSWORD: True, ATTR_VERSION: __version__, + ATTR_CURRENCY: None, } with suppress(NoURLAvailableError): diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py index c9e12a20863..a87cae09b1a 100644 --- a/homeassistant/components/apns/notify.py +++ b/homeassistant/components/apns/notify.py @@ -184,7 +184,7 @@ class ApnsNotificationService(BaseNotificationService): def write_devices(self): """Write all known devices to file.""" - with open(self.yaml_path, "w+") as out: + with open(self.yaml_path, "w+", encoding="utf8") as out: for device in self.devices.values(): _write_device(out, device) @@ -202,7 +202,7 @@ class ApnsNotificationService(BaseNotificationService): if current_device is None: self.devices[push_id] = device - with open(self.yaml_path, "a") as out: + with open(self.yaml_path, "a", encoding="utf8") as out: _write_device(out, device) return True diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index e8b900d8213..c4efa4ca09a 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -57,10 +57,10 @@ async def async_setup_entry(hass, entry): async def setup_platforms(): """Set up platforms and initiate connection.""" await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) await manager.init() @@ -83,12 +83,17 @@ async def async_unload_entry(hass, entry): class AppleTVEntity(Entity): """Device that sends commands to an Apple TV.""" + _attr_should_poll = False + def __init__(self, name, identifier, manager): """Initialize device.""" self.atv = None self.manager = manager - self._name = name - self._identifier = identifier + self._attr_name = name + self._attr_unique_id = identifier + self._attr_device_info = { + "identifiers": {(DOMAIN, identifier)}, + } async def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" @@ -109,13 +114,13 @@ class AppleTVEntity(Entity): self.async_on_remove( async_dispatcher_connect( - self.hass, f"{SIGNAL_CONNECTED}_{self._identifier}", _async_connected + self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected ) ) self.async_on_remove( async_dispatcher_connect( self.hass, - f"{SIGNAL_DISCONNECTED}_{self._identifier}", + f"{SIGNAL_DISCONNECTED}_{self.unique_id}", _async_disconnected, ) ) @@ -126,28 +131,6 @@ class AppleTVEntity(Entity): def async_device_disconnected(self): """Handle when connection was lost to device.""" - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._identifier - - @property - def should_poll(self): - """No polling needed for Apple TV.""" - return False - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self._identifier)}, - } - class AppleTVManager: """Connection and power manager for an Apple TV. diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9afcb7a61ca..56ad1e83e23 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -56,7 +56,7 @@ async def device_scan(identifier, loop, cache=None): if matches: return cache, matches[0] - for hosts in [_host_filter(), None]: + for hosts in (_host_filter(), None): scan_result = await scan(loop, timeout=3, hosts=hosts) matches = [atv for atv in scan_result if _filter_device(atv)] diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index d4eb322f4d7..a726e616641 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,7 +3,7 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.8.1"], + "requirements": ["pyatv==0.8.2"], "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], "after_dependencies": ["discovery"], "codeowners": ["@postlund"], diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index a855fc6b53e..09ecc01015c 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -75,6 +75,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Representation of an Apple TV media player.""" + _attr_supported_features = SUPPORT_APPLE_TV + def __init__(self, name, identifier, manager, **kwargs): """Initialize the Apple TV media player.""" super().__init__(name, identifier, manager, **kwargs) @@ -229,11 +231,6 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): return self._playing.shuffle != ShuffleState.Off return None - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_APPLE_TV - def _is_feature_available(self, feature): """Return if a feature is available.""" if self.atv and self._playing: diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index 3d88bddcbc9..853ea29fcf5 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -34,11 +34,6 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): """Return true if device is on.""" return self.atv is not None - @property - def should_poll(self): - """No polling needed for Apple TV.""" - return False - async def async_turn_on(self, **kwargs): """Turn the device on.""" await self.manager.connect() @@ -53,7 +48,7 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) if not self.is_on: - _LOGGER.error("Unable to send commands, not connected to %s", self._name) + _LOGGER.error("Unable to send commands, not connected to %s", self.name) return for _ in range(num_repeats): diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 00dd92cac89..d9fe17863dd 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -1,5 +1,4 @@ { - "title": "Apple TV", "config": { "flow_title": "{name}", "step": { diff --git a/homeassistant/components/apple_tv/translations/ar.json b/homeassistant/components/apple_tv/translations/ar.json new file mode 100644 index 00000000000..07ffa860c9c --- /dev/null +++ b/homeassistant/components/apple_tv/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "reconfigure": { + "description": "\u064a\u0648\u0627\u062c\u0647 Apple TV \u0647\u0630\u0627 \u0628\u0639\u0636 \u0627\u0644\u0635\u0639\u0648\u0628\u0627\u062a \u0641\u064a \u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0648\u064a\u062c\u0628 \u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646\u0647.", + "title": "\u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646 \u0627\u0644\u062c\u0647\u0627\u0632" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/apple_tv/translations/hu.json b/homeassistant/components/apple_tv/translations/hu.json index 72b334849a0..2b6275fc9f5 100644 --- a/homeassistant/components/apple_tv/translations/hu.json +++ b/homeassistant/components/apple_tv/translations/hu.json @@ -3,6 +3,9 @@ "abort": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "backoff": "Az eszk\u00f6z jelenleg nem fogadja el a p\u00e1ros\u00edt\u00e1si k\u00e9relmeket (lehet, hogy t\u00fal sokszor adott meg \u00e9rv\u00e9nytelen PIN-k\u00f3dot), pr\u00f3b\u00e1lkozzon \u00fajra k\u00e9s\u0151bb.", + "device_did_not_pair": "A p\u00e1ros\u00edt\u00e1s folyamat\u00e1t az eszk\u00f6zr\u0151l nem pr\u00f3b\u00e1lt\u00e1k befejezni.", + "invalid_config": "Az eszk\u00f6z konfigur\u00e1l\u00e1sa nem teljes. K\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra hozz\u00e1adni.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -10,35 +13,52 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "no_usable_service": "Tal\u00e1ltunk egy eszk\u00f6zt, de nem tudtuk azonos\u00edtani, hogyan lehetne kapcsolatot l\u00e9tes\u00edteni vele. Ha tov\u00e1bbra is ezt az \u00fczenetet l\u00e1tja, pr\u00f3b\u00e1lja meg megadni az IP-c\u00edm\u00e9t, vagy ind\u00edtsa \u00fajra az Apple TV-t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "{name}", "step": { "confirm": { + "description": "Arra k\u00e9sz\u00fcl, hogy felvegye a (z) {name} nev\u0171 Apple TV-t a Home Assistant programba. \n\n ** A folyamat befejez\u00e9s\u00e9hez t\u00f6bb PIN-k\u00f3dot kell megadnia. ** \n\n Felh\u00edvjuk figyelm\u00e9t, hogy ezzel az integr\u00e1ci\u00f3val * nem fogja tudni kikapcsolni az Apple TV-t. Csak a Home Assistant m\u00e9dialej\u00e1tsz\u00f3ja kapcsol ki!", "title": "Apple TV sikeresen hozz\u00e1adva" }, "pair_no_pin": { + "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a(z) {protocol} szolg\u00e1ltat\u00e1shoz. A folytat\u00e1shoz k\u00e9rj\u00fck, \u00edrja be az Apple TV {pin} -t.", "title": "P\u00e1ros\u00edt\u00e1s" }, "pair_with_pin": { "data": { "pin": "PIN-k\u00f3d" }, + "description": "P\u00e1ros\u00edt\u00e1sra van sz\u00fcks\u00e9g a(z) {protocol} protokollhoz. K\u00e9rj\u00fck, adja meg a k\u00e9perny\u0151n megjelen\u0151 PIN-k\u00f3dot. A vezet\u0151 null\u00e1kat el kell hagyni, azaz \u00edrja be a 123 \u00e9rt\u00e9ket, ha a megjelen\u00edtett k\u00f3d 0123.", "title": "P\u00e1ros\u00edt\u00e1s" }, "reconfigure": { + "description": "Ez az Apple TV csatlakoz\u00e1si neh\u00e9zs\u00e9gekkel k\u00fczd, ez\u00e9rt \u00fajra kell konfigur\u00e1lni.", "title": "Eszk\u00f6z \u00fajrakonfigur\u00e1l\u00e1sa" }, "service_problem": { + "description": "Hiba t\u00f6rt\u00e9nt a(z) \" {protocol} \" protokoll p\u00e1ros\u00edt\u00e1sakor. Ez figyelmen k\u00edv\u00fcl lett hagyva.", "title": "Nem siker\u00fclt hozz\u00e1adni a szolg\u00e1ltat\u00e1st" }, "user": { "data": { "device_input": "Eszk\u00f6z" }, + "description": "El\u0151sz\u00f6r \u00edrja be a hozz\u00e1adni k\u00edv\u00e1nt Apple TV eszk\u00f6znev\u00e9t (pl. Konyha vagy H\u00e1l\u00f3szoba) vagy IP-c\u00edm\u00e9t. Ha valamilyen eszk\u00f6zt automatikusan tal\u00e1ltak a h\u00e1l\u00f3zat\u00e1n, az al\u00e1bb l\u00e1that\u00f3. \n\n Ha nem l\u00e1tja eszk\u00f6z\u00e9t, vagy b\u00e1rmilyen probl\u00e9m\u00e1t tapasztal, pr\u00f3b\u00e1lja meg megadni az eszk\u00f6z IP-c\u00edm\u00e9t. \n\n {devices}", "title": "\u00daj Apple TV be\u00e1ll\u00edt\u00e1sa" } } }, + "options": { + "step": { + "init": { + "data": { + "start_off": "A Home Assistant ind\u00edt\u00e1sakor ne kapcsolja be az eszk\u00f6zt" + }, + "description": "Konfigur\u00e1lja az eszk\u00f6z \u00e1ltal\u00e1nos be\u00e1ll\u00edt\u00e1sait" + } + } + }, "title": "Apple TV" } \ No newline at end of file diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index ef3686fcd00..f86a5a4648d 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -178,7 +178,7 @@ class AprsListenerThread(threading.Thread): _LOGGER.warning( "APRS message contained invalid posambiguity: %s", str(pos_amb) ) - for attr in [ATTR_ALTITUDE, ATTR_COMMENT, ATTR_COURSE, ATTR_SPEED]: + for attr in (ATTR_ALTITUDE, ATTR_COMMENT, ATTR_COURSE, ATTR_SPEED): if attr in msg: attrs[attr] = msg[attr] diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 315b039f778..01f31757c9d 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, POWER_WATT, TEMP_CELSIUS, @@ -21,18 +22,28 @@ SALT_UNITS = ["g/L", "PPM"] WATT_UNITS = [POWER_WATT, POWER_WATT] NO_UNITS = [None, None] -# sensor_type [ description, unit, icon ] +# sensor_type [ description, unit, icon, device_class ] # sensor_type corresponds to property names in aqualogic.core.AquaLogic SENSOR_TYPES = { - "air_temp": ["Air Temperature", TEMP_UNITS, "mdi:thermometer"], - "pool_temp": ["Pool Temperature", TEMP_UNITS, "mdi:oil-temperature"], - "spa_temp": ["Spa Temperature", TEMP_UNITS, "mdi:oil-temperature"], - "pool_chlorinator": ["Pool Chlorinator", PERCENT_UNITS, "mdi:gauge"], - "spa_chlorinator": ["Spa Chlorinator", PERCENT_UNITS, "mdi:gauge"], - "salt_level": ["Salt Level", SALT_UNITS, "mdi:gauge"], - "pump_speed": ["Pump Speed", PERCENT_UNITS, "mdi:speedometer"], - "pump_power": ["Pump Power", WATT_UNITS, "mdi:gauge"], - "status": ["Status", NO_UNITS, "mdi:alert"], + "air_temp": ["Air Temperature", TEMP_UNITS, None, DEVICE_CLASS_TEMPERATURE], + "pool_temp": [ + "Pool Temperature", + TEMP_UNITS, + "mdi:oil-temperature", + DEVICE_CLASS_TEMPERATURE, + ], + "spa_temp": [ + "Spa Temperature", + TEMP_UNITS, + "mdi:oil-temperature", + DEVICE_CLASS_TEMPERATURE, + ], + "pool_chlorinator": ["Pool Chlorinator", PERCENT_UNITS, "mdi:gauge", None], + "spa_chlorinator": ["Spa Chlorinator", PERCENT_UNITS, "mdi:gauge", None], + "salt_level": ["Salt Level", SALT_UNITS, "mdi:gauge", None], + "pump_speed": ["Pump Speed", PERCENT_UNITS, "mdi:speedometer", None], + "pump_power": ["Pump Power", WATT_UNITS, "mdi:gauge", None], + "status": ["Status", NO_UNITS, "mdi:alert", None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -58,41 +69,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AquaLogicSensor(SensorEntity): """Sensor implementation for the AquaLogic component.""" + _attr_should_poll = False + def __init__(self, processor, sensor_type): """Initialize sensor.""" self._processor = processor self._type = sensor_type - self._state = None - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def name(self): - """Return the name of the sensor.""" - return f"AquaLogic {SENSOR_TYPES[self._type][0]}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement the value is expressed in.""" - panel = self._processor.panel - if panel is None: - return None - if panel.is_metric: - return SENSOR_TYPES[self._type][1][0] - return SENSOR_TYPES[self._type][1][1] - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self._type][2] + self._attr_name = f"AquaLogic {SENSOR_TYPES[sensor_type][0]}" + self._attr_icon = SENSOR_TYPES[sensor_type][2] async def async_added_to_hass(self): """Register callbacks.""" @@ -107,5 +91,11 @@ class AquaLogicSensor(SensorEntity): """Update callback.""" panel = self._processor.panel if panel is not None: - self._state = getattr(panel, self._type) - self.async_write_ha_state() + if panel.is_metric: + self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][0] + self._attr_state = getattr(panel, self._type) + self.async_write_ha_state() + else: + self._attr_unit_of_measurement = SENSOR_TYPES[self._type][1][1] + else: + self._attr_unit_of_measurement = None diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 08bba4cbd2d..c05bacc5f03 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -44,10 +44,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AquaLogicSwitch(SwitchEntity): """Switch implementation for the AquaLogic component.""" + _attr_should_poll = False + def __init__(self, processor, switch_type): """Initialize switch.""" self._processor = processor - self._type = switch_type self._state_name = { "lights": States.LIGHTS, "filter": States.FILTER, @@ -60,16 +61,7 @@ class AquaLogicSwitch(SwitchEntity): "aux_6": States.AUX_6, "aux_7": States.AUX_7, }[switch_type] - - @property - def name(self): - """Return the name of the switch.""" - return f"AquaLogic {SWITCH_TYPES[self._type]}" - - @property - def should_poll(self): - """Return the polling state.""" - return False + self._attr_name = f"AquaLogic {SWITCH_TYPES[switch_type]}" @property def is_on(self): diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 35c7e2ae646..50dd70bddcc 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -124,33 +124,30 @@ def _retry(func): class SharpAquosTVDevice(MediaPlayerEntity): """Representation of a Aquos TV.""" + _attr_source_list = list(SOURCES.values()) + _attr_supported_features = SUPPORT_SHARPTV + def __init__(self, name, remote, power_on_enabled=False): """Initialize the aquos device.""" - self._supported_features = SUPPORT_SHARPTV self._power_on_enabled = power_on_enabled - if self._power_on_enabled: - self._supported_features |= SUPPORT_TURN_ON + if power_on_enabled: + self._attr_supported_features |= SUPPORT_TURN_ON # Save a reference to the imported class - self._name = name + self._attr_name = name # Assume that the TV is not muted - self._muted = False - self._state = None self._remote = remote - self._volume = 0 - self._source = None - self._source_list = list(SOURCES.values()) def set_state(self, state): """Set TV state.""" - self._state = state + self._attr_state = state @_retry def update(self): """Retrieve the latest data.""" if self._remote.power() == 1: - self._state = STATE_ON + self._attr_state = STATE_ON else: - self._state = STATE_OFF + self._attr_state = STATE_OFF # Set TV to be able to remotely power on if self._power_on_enabled: self._remote.power_on_command_settings(2) @@ -158,48 +155,13 @@ class SharpAquosTVDevice(MediaPlayerEntity): self._remote.power_on_command_settings(0) # Get mute state if self._remote.mute() == 2: - self._muted = False + self._attr_is_volume_muted = False else: - self._muted = True + self._attr_is_volume_muted = True # Get source - self._source = SOURCES.get(self._remote.input()) + self._attr_source = SOURCES.get(self._remote.input()) # Get volume - self._volume = self._remote.volume() / 60 - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def source(self): - """Return the current source.""" - return self._source - - @property - def source_list(self): - """Return the source list.""" - return self._source_list - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._muted - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return self._supported_features + self._attr_volume_level = self._remote.volume() / 60 @_retry def turn_off(self): @@ -209,12 +171,12 @@ class SharpAquosTVDevice(MediaPlayerEntity): @_retry def volume_up(self): """Volume up the media player.""" - self._remote.volume(int(self._volume * 60) + 2) + self._remote.volume(int(self.volume_level * 60) + 2) @_retry def volume_down(self): """Volume down media player.""" - self._remote.volume(int(self._volume * 60) - 2) + self._remote.volume(int(self.volume_level * 60) - 2) @_retry def set_volume_level(self, volume): diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index 905e31c798b..c1df4fc0587 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -43,7 +43,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): async def _stop(_): asyncio.gather( - *[_await_cancel(task) for task in hass.data[DOMAIN_DATA_TASKS].values()] + *(_await_cancel(task) for task in hass.data[DOMAIN_DATA_TASKS].values()) ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 8a119d020fe..e9e5e29a1c7 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -52,7 +52,7 @@ async def async_setup_entry( State(client, zone), config_entry.unique_id or config_entry.entry_id, ) - for zone in [1, 2] + for zone in (1, 2) ], True, ) @@ -63,6 +63,8 @@ async def async_setup_entry( class ArcamFmj(MediaPlayerEntity): """Representation of a media device.""" + _attr_should_poll = False + def __init__( self, device_name, @@ -72,9 +74,9 @@ class ArcamFmj(MediaPlayerEntity): """Initialize device.""" self._state = state self._device_name = device_name - self._name = f"{device_name} - Zone: {state.zn}" + self._attr_name = f"{device_name} - Zone: {state.zn}" self._uuid = uuid - self._support = ( + self._attr_supported_features = ( SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA | SUPPORT_BROWSE_MEDIA @@ -85,7 +87,9 @@ class ArcamFmj(MediaPlayerEntity): | SUPPORT_TURN_ON ) if state.zn == 1: - self._support |= SUPPORT_SELECT_SOUND_MODE + self._attr_supported_features |= SUPPORT_SELECT_SOUND_MODE + self._attr_unique_id = f"{uuid}-{state.zn}" + self._attr_entity_registry_enabled_default = state.zn == 1 def _get_2ch(self): """Return if source is 2 channel or not.""" @@ -101,14 +105,11 @@ class ArcamFmj(MediaPlayerEntity): ) @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._state.zn == 1 - - @property - def unique_id(self): - """Return unique identifier if known.""" - return f"{self._uuid}-{self._state.zn}" + def state(self): + """Return the state of the device.""" + if self._state.get_power(): + return STATE_ON + return STATE_OFF @property def device_info(self): @@ -123,28 +124,6 @@ class ArcamFmj(MediaPlayerEntity): "manufacturer": "Arcam", } - @property - def should_poll(self) -> bool: - """No need to poll.""" - return False - - @property - def name(self): - """Return the name of the controlled device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - if self._state.get_power(): - return STATE_ON - return STATE_OFF - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return self._support - async def async_added_to_hass(self): """Once registered, add listener for events.""" await self._state.start() diff --git a/homeassistant/components/arcam_fmj/translations/he.json b/homeassistant/components/arcam_fmj/translations/he.json index 0a4bd9ca12a..c07b9af0c67 100644 --- a/homeassistant/components/arcam_fmj/translations/he.json +++ b/homeassistant/components/arcam_fmj/translations/he.json @@ -5,10 +5,16 @@ "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "flow_title": "{host}", "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Arcam FMJ \u05d1- '{host}' \u05dc-Home Assistant?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea Arcam FMJ \u05d1-`{host}` \u05dc-Home Assistant?" }, "user": { "data": { diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py index 588a652660a..fa624a7d167 100644 --- a/homeassistant/components/arduino/sensor.py +++ b/homeassistant/components/arduino/sensor.py @@ -35,24 +35,11 @@ class ArduinoSensor(SensorEntity): def __init__(self, name, pin, pin_type, board): """Initialize the sensor.""" self._pin = pin - self._name = name - self.pin_type = pin_type - self.direction = "in" - self._value = None + self._attr_name = name - board.set_mode(self._pin, self.direction, self.pin_type) + board.set_mode(self._pin, "in", pin_type) self._board = board - @property - def state(self): - """Return the state of the sensor.""" - return self._value - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - def update(self): """Get the latest value from the pin.""" - self._value = self._board.get_analog_inputs()[self._pin][1] + self._attr_state = self._board.get_analog_inputs()[self._pin][1] diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py index 6ee742fd506..9368426b38e 100644 --- a/homeassistant/components/arduino/switch.py +++ b/homeassistant/components/arduino/switch.py @@ -43,11 +43,9 @@ class ArduinoSwitch(SwitchEntity): def __init__(self, pin, options, board): """Initialize the Pin.""" self._pin = pin - self._name = options[CONF_NAME] - self.pin_type = CONF_TYPE - self.direction = "out" + self._attr_name = options[CONF_NAME] - self._state = options[CONF_INITIAL] + self._attr_is_on = options[CONF_INITIAL] if options[CONF_NEGATE]: self.turn_on_handler = board.set_digital_out_low @@ -56,25 +54,15 @@ class ArduinoSwitch(SwitchEntity): self.turn_on_handler = board.set_digital_out_high self.turn_off_handler = board.set_digital_out_low - board.set_mode(self._pin, self.direction, self.pin_type) - (self.turn_on_handler if self._state else self.turn_off_handler)(pin) - - @property - def name(self): - """Get the name of the pin.""" - return self._name - - @property - def is_on(self): - """Return true if pin is high/on.""" - return self._state + board.set_mode(pin, "out", CONF_TYPE) + (self.turn_on_handler if self.is_on else self.turn_off_handler)(pin) def turn_on(self, **kwargs): """Turn the pin to high/on.""" - self._state = True + self._attr_is_on = True self.turn_on_handler(self._pin) def turn_off(self, **kwargs): """Turn the pin to low/off.""" - self._state = False + self._attr_is_on = False self.turn_off_handler(self._pin) diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index 3cd9038f1a8..d59e6d0cccb 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -73,34 +73,18 @@ class ArestBinarySensor(BinarySensorEntity): def __init__(self, arest, resource, name, device_class, pin): """Initialize the aREST device.""" self.arest = arest - self._resource = resource - self._name = name - self._device_class = device_class - self._pin = pin + self._attr_name = name + self._attr_device_class = device_class - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return bool(self.arest.data.get("state")) - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() + self._attr_is_on = bool(self.arest.data.get("state")) class ArestData: diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 061c15eafb0..7129b989f47 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -139,48 +139,27 @@ class ArestSensor(SensorEntity): ): """Initialize the sensor.""" self.arest = arest - self._resource = resource - self._name = f"{location.title()} {name.title()}" + self._attr_name = f"{location.title()} {name.title()}" self._variable = variable - self._pin = pin - self._state = None - self._unit_of_measurement = unit_of_measurement + self._attr_unit_of_measurement = unit_of_measurement self._renderer = renderer - if self._pin is not None: - request = requests.get(f"{self._resource}/mode/{self._pin}/i", timeout=10) + if pin is not None: + request = requests.get(f"{resource}/mode/{pin}/i", timeout=10) if request.status_code != HTTP_OK: - _LOGGER.error("Can't set mode of %s", self._resource) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def state(self): - """Return the state of the sensor.""" - values = self.arest.data - - if "error" in values: - return values["error"] - - value = self._renderer(values.get("value", values.get(self._variable, None))) - return value + _LOGGER.error("Can't set mode of %s", resource) def update(self): """Get the latest data from aREST API.""" self.arest.update() - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self.arest.available + self._attr_available = self.arest.available + values = self.arest.data + if "error" in values: + self._attr_state = values["error"] + else: + self._attr_state = self._renderer( + values.get("value", values.get(self._variable, None)) + ) class ArestData: @@ -191,7 +170,7 @@ class ArestData: self._resource = resource self._pin = pin self.data = {} - self.available = True + self._attr_available = True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -212,7 +191,7 @@ class ArestData: f"{self._resource}/digital/{self._pin}", timeout=10 ) self.data = {"value": response.json()["return_value"]} - self.available = True + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.error("No route to device %s", self._resource) - self.available = False + self._attr_available = False diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index ddd6b51f76d..d20eb7a5f8d 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -86,24 +86,8 @@ class ArestSwitchBase(SwitchEntity): def __init__(self, resource, location, name): """Initialize the switch.""" self._resource = resource - self._name = f"{location.title()} {name.title()}" - self._state = None - self._available = True - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = f"{location.title()} {name.title()}" + self._attr_available = True class ArestSwitchFunction(ArestSwitchBase): @@ -134,7 +118,7 @@ class ArestSwitchFunction(ArestSwitchBase): ) if request.status_code == HTTP_OK: - self._state = True + self._attr_is_on = True else: _LOGGER.error("Can't turn on function %s at %s", self._func, self._resource) @@ -145,7 +129,7 @@ class ArestSwitchFunction(ArestSwitchBase): ) if request.status_code == HTTP_OK: - self._state = False + self._attr_is_on = False else: _LOGGER.error( "Can't turn off function %s at %s", self._func, self._resource @@ -155,11 +139,11 @@ class ArestSwitchFunction(ArestSwitchBase): """Get the latest data from aREST API and update the state.""" try: request = requests.get(f"{self._resource}/{self._func}", timeout=10) - self._state = request.json()["return_value"] != 0 - self._available = True + self._attr_is_on = request.json()["return_value"] != 0 + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False class ArestSwitchPin(ArestSwitchBase): @@ -171,10 +155,10 @@ class ArestSwitchPin(ArestSwitchBase): self._pin = pin self.invert = invert - request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) if request.status_code != HTTP_OK: _LOGGER.error("Can't set mode") - self._available = False + self._attr_available = False def turn_on(self, **kwargs): """Turn the device on.""" @@ -183,7 +167,7 @@ class ArestSwitchPin(ArestSwitchBase): f"{self._resource}/digital/{self._pin}/{turn_on_payload}", timeout=10 ) if request.status_code == HTTP_OK: - self._state = True + self._attr_is_on = True else: _LOGGER.error("Can't turn on pin %s at %s", self._pin, self._resource) @@ -194,7 +178,7 @@ class ArestSwitchPin(ArestSwitchBase): f"{self._resource}/digital/{self._pin}/{turn_off_payload}", timeout=10 ) if request.status_code == HTTP_OK: - self._state = False + self._attr_is_on = False else: _LOGGER.error("Can't turn off pin %s at %s", self._pin, self._resource) @@ -203,8 +187,8 @@ class ArestSwitchPin(ArestSwitchBase): try: request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) - self._state = request.json()["return_value"] != status_value - self._available = True + self._attr_is_on = request.json()["return_value"] != status_value + self._attr_available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) - self._available = False + self._attr_available = False diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index 91fb2a6a33e..b9a1004ac70 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -14,6 +14,7 @@ from homeassistant.components.alarm_control_panel.const import ( ) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_DEVICE_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, @@ -33,8 +34,6 @@ CONF_HOME_MODE_NAME = "home_mode_name" CONF_AWAY_MODE_NAME = "away_mode_name" CONF_NIGHT_MODE_NAME = "night_mode_name" -DISARMED = "disarmed" - ICON = "mdi:security" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -69,18 +68,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ArloBaseStation(AlarmControlPanelEntity): """Representation of an Arlo Alarm Control Panel.""" + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) + _attr_icon = ICON + def __init__(self, data, home_mode_name, away_mode_name, night_mode_name): """Initialize the alarm control panel.""" self._base_station = data self._home_mode_name = home_mode_name self._away_mode_name = away_mode_name self._night_mode_name = night_mode_name - self._state = None - - @property - def icon(self): - """Return icon.""" - return ICON + self._attr_name = data.name + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_DEVICE_ID: data.device_id, + } async def async_added_to_hass(self): """Register callbacks.""" @@ -95,28 +98,15 @@ class ArloBaseStation(AlarmControlPanelEntity): """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - def update(self): """Update the state of the device.""" _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) mode = self._base_station.mode - if mode: - self._state = self._get_state_from_mode(mode) - else: - self._state = None + self._attr_state = self._get_state_from_mode(mode) if mode else None def alarm_disarm(self, code=None): """Send disarm command.""" - self._base_station.mode = DISARMED + self._base_station.mode = STATE_ALARM_DISARMED def alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" @@ -130,24 +120,11 @@ class ArloBaseStation(AlarmControlPanelEntity): """Send arm night command. Uses custom mode.""" self._base_station.mode = self._night_mode_name - @property - def name(self): - """Return the name of the base station.""" - return self._base_station.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._base_station.device_id, - } - def _get_state_from_mode(self, mode): """Convert Arlo mode to Home Assistant state.""" if mode == ARMED: return STATE_ALARM_ARMED_AWAY - if mode == DISARMED: + if mode == STATE_ALARM_DISARMED: return STATE_ALARM_DISARMED if mode == self._home_mode_name: return STATE_ALARM_ARMED_HOME diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index c1848661429..87c6216e56d 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -55,7 +55,7 @@ class ArloCam(Camera): """Initialize an Arlo camera.""" super().__init__() self._camera = camera - self._name = self._camera.name + self._attr_name = camera.name self._motion_status = False self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) @@ -102,11 +102,6 @@ class ArloCam(Camera): finally: await stream.close() - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index ba9166d1af5..2300319f9a4 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -4,7 +4,13 @@ import logging from homeassistant.components import mqtt from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEGREE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + DEGREE, + DEVICE_CLASS_TEMPERATURE, + PRECIPITATION_INCHES, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import callback from homeassistant.util import slugify @@ -30,14 +36,20 @@ def discover_sensors(topic, payload): unit = TEMP_FAHRENHEIT else: unit = TEMP_CELSIUS - return ArwnSensor(topic, name, "temp", unit) + return ArwnSensor( + topic, name, "temp", unit, device_class=DEVICE_CLASS_TEMPERATURE + ) if domain == "moisture": name = f"{parts[2]} Moisture" return ArwnSensor(topic, name, "moisture", unit, "mdi:water-percent") if domain == "rain": if len(parts) >= 3 and parts[2] == "today": return ArwnSensor( - topic, "Rain Since Midnight", "since_midnight", "in", "mdi:water" + topic, + "Rain Since Midnight", + "since_midnight", + PRECIPITATION_INCHES, + "mdi:water", ) return ( ArwnSensor(topic + "/total", "Total Rainfall", "total", unit, "mdi:water"), @@ -117,58 +129,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class ArwnSensor(SensorEntity): """Representation of an ARWN sensor.""" - def __init__(self, topic, name, state_key, units, icon=None): + _attr_should_poll = False + + def __init__(self, topic, name, state_key, units, icon=None, device_class=None): """Initialize the sensor.""" - self.hass = None self.entity_id = _slug(name) - self._name = name + self._attr_name = name # This mqtt topic for the sensor which is its uid - self._uid = topic + self._attr_unique_id = topic self._state_key = state_key - self.event = {} - self._unit_of_measurement = units - self._icon = icon + self._attr_unit_of_measurement = units + self._attr_icon = icon + self._attr_device_class = device_class def set_event(self, event): """Update the sensor with the most recent event.""" - self.event = {} - self.event.update(event) + ev = {} + ev.update(event) + self._attr_extra_state_attributes = ev + self._attr_state = ev.get(self._state_key, None) self.async_write_ha_state() - - @property - def state(self): - """Return the state of the device.""" - return self.event.get(self._state_key, None) - - @property - def name(self): - """Get the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID. - - This is based on the topic that comes from mqtt - """ - return self._uid - - @property - def extra_state_attributes(self): - """Return all the state attributes.""" - return self.event - - @property - def unit_of_measurement(self): - """Return the unit of measurement the state is expressed in.""" - return self._unit_of_measurement - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def icon(self): - """Return the icon of device based on its type.""" - return self._icon diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index a0c7ec0e27a..0b5d81e3de9 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -1,15 +1,12 @@ """Support for ASUSWRT routers.""" from __future__ import annotations -from typing import Any - from homeassistant.components.device_tracker import SOURCE_TYPE_ROUTER from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from .const import DATA_ASUSWRT, DOMAIN from .router import AsusWrtRouter @@ -55,20 +52,14 @@ def add_entities(router, async_add_entities, tracked): class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" + _attr_should_poll = False + def __init__(self, router: AsusWrtRouter, device) -> None: """Initialize a AsusWrt device.""" self._router = router self._device = device - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.mac - - @property - def name(self) -> str: - """Return the name.""" - return self._device.name or DEFAULT_DEVICE_NAME + self._attr_unique_id = device.mac + self._attr_name = device.name or DEFAULT_DEVICE_NAME @property def is_connected(self): @@ -80,16 +71,6 @@ class AsusWrtDevice(ScannerEntity): """Return the source type.""" return SOURCE_TYPE_ROUTER - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the attributes.""" - attrs = {} - if self._device.last_activity: - attrs["last_time_reachable"] = self._device.last_activity.isoformat( - timespec="seconds" - ) - return attrs - @property def hostname(self) -> str: """Return the hostname of device.""" @@ -105,26 +86,20 @@ class AsusWrtDevice(ScannerEntity): """Return the mac address of the device.""" return self._device.mac - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - data = { - "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, - } - if self._device.name: - data["default_name"] = self._device.name - - return data - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - @callback def async_on_demand_update(self): """Update state.""" self._device = self._router.devices[self._device.mac] + self._attr_device_info = { + "connections": {(CONNECTION_NETWORK_MAC, self._device.mac)}, + } + if self._device.name: + self._attr_device_info["default_name"] = self._device.name + self._attr_extra_state_attributes = {} + if self._device.last_activity: + self._attr_extra_state_attributes[ + "last_time_reachable" + ] = self._device.last_activity.isoformat(timespec="seconds") self.async_write_ha_state() async def async_added_to_hass(self): diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 3c911d7712e..9d1bcb35c9e 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -299,9 +299,9 @@ class AsusWrtRouter: ) track_unknown = self._options.get(CONF_TRACK_UNKNOWN, DEFAULT_TRACK_UNKNOWN) - for device_mac in self._devices: + for device_mac, device in self._devices.items(): dev_info = wrt_devices.get(device_mac) - self._devices[device_mac].update(dev_info, consider_home) + device.update(dev_info, consider_home) for device_mac, dev_info in wrt_devices.items(): if device_mac in self._devices: diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index 086c7373a4e..679ae832394 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -120,15 +120,15 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self._router = router self._sensor_type = sensor_type - self._sensor_def = sensor_def - self._name = f"{DEFAULT_PREFIX} {sensor_def[SENSOR_NAME]}" - self._unique_id = f"{DOMAIN} {self._name}" + self._attr_name = f"{DEFAULT_PREFIX} {sensor_def[SENSOR_NAME]}" self._factor = sensor_def.get(SENSOR_FACTOR) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._sensor_def.get(SENSOR_DEFAULT_ENABLED, False) + self._attr_unique_id = f"{DOMAIN} {self.name}" + self._attr_entity_registry_enabled_default = sensor_def.get( + SENSOR_DEFAULT_ENABLED, False + ) + self._attr_unit_of_measurement = sensor_def.get(SENSOR_UNIT) + self._attr_icon = sensor_def.get(SENSOR_ICON) + self._attr_device_class = sensor_def.get(SENSOR_DEVICE_CLASS) @property def state(self) -> str: @@ -140,31 +140,6 @@ class AsusWrtSensor(CoordinatorEntity, SensorEntity): return round(state / self._factor, 2) return state - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def unit_of_measurement(self) -> str: - """Return the unit.""" - return self._sensor_def.get(SENSOR_UNIT) - - @property - def icon(self) -> str: - """Return the icon.""" - return self._sensor_def.get(SENSOR_ICON) - - @property - def device_class(self) -> str: - """Return the device_class.""" - return self._sensor_def.get(SENSOR_DEVICE_CLASS) - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the attributes.""" diff --git a/homeassistant/components/asuswrt/translations/de.json b/homeassistant/components/asuswrt/translations/de.json index bf7e2230810..6a860311a32 100644 --- a/homeassistant/components/asuswrt/translations/de.json +++ b/homeassistant/components/asuswrt/translations/de.json @@ -23,8 +23,8 @@ "ssh_key": "Pfad zu deiner SSH-Schl\u00fcsseldatei (anstelle des Passworts)", "username": "Benutzername" }, - "description": "Einstellen der erforderlichen Parameter f\u00fcr die Verbindung mit Ihrem Router.", - "title": "" + "description": "Einstellen der erforderlichen Parameter f\u00fcr die Verbindung mit deinem Router.", + "title": "AsusWRT" } } }, diff --git a/homeassistant/components/asuswrt/translations/hu.json b/homeassistant/components/asuswrt/translations/hu.json index 4f47781a15c..891150c1038 100644 --- a/homeassistant/components/asuswrt/translations/hu.json +++ b/homeassistant/components/asuswrt/translations/hu.json @@ -6,6 +6,8 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "pwd_and_ssh": "Csak jelsz\u00f3 vagy SSH kulcsf\u00e1jlt adjon meg", + "pwd_or_ssh": "K\u00e9rj\u00fck, adja meg a jelsz\u00f3t vagy az SSH kulcsf\u00e1jlt", "ssh_not_file": "Az SSH kulcsf\u00e1jl nem tal\u00e1lhat\u00f3", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -17,8 +19,11 @@ "name": "N\u00e9v", "password": "Jelsz\u00f3", "port": "Port", + "protocol": "Haszn\u00e1lhat\u00f3 kommunik\u00e1ci\u00f3s protokoll", + "ssh_key": "Az SSH kulcsf\u00e1jl el\u00e9r\u00e9si \u00fatja (jelsz\u00f3 helyett)", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "\u00c1ll\u00edtsa be a sz\u00fcks\u00e9ges param\u00e9tert az \u00fatv\u00e1laszt\u00f3hoz val\u00f3 csatlakoz\u00e1shoz", "title": "AsusWRT" } } @@ -26,6 +31,13 @@ "options": { "step": { "init": { + "data": { + "consider_home": "V\u00e1rakoz\u00e1si m\u00e1sodpercek, miel\u0151tt egy eszk\u00f6zt lecsatlakoztat", + "dnsmasq": "A dnsmasq.leasing f\u00e1jlok helye az \u00fatv\u00e1laszt\u00f3n", + "interface": "Az a fel\u00fclet, amelyr\u0151l statisztik\u00e1kat szeretne kapni (pl. eth0, eth1 stb.)", + "require_ip": "Az eszk\u00f6z\u00f6knek IP-vel kell rendelkezni\u00fck (hozz\u00e1f\u00e9r\u00e9si pont m\u00f3dhoz)", + "track_unknown": "Az ismeretlen / n\u00e9v n\u00e9lk\u00fcli eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se" + }, "title": "AsusWRT Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/atag/__init__.py b/homeassistant/components/atag/__init__.py index af5eff67f57..de785a3a317 100644 --- a/homeassistant/components/atag/__init__.py +++ b/homeassistant/components/atag/__init__.py @@ -75,27 +75,16 @@ class AtagEntity(CoordinatorEntity): super().__init__(coordinator) self._id = atag_id - self._name = DOMAIN.title() + self._attr_name = DOMAIN.title() + self._attr_unique_id = f"{coordinator.data.id}-{atag_id}" @property def device_info(self) -> DeviceInfo: """Return info for device registry.""" - device = self.coordinator.data.id - version = self.coordinator.data.apiversion return { - "identifiers": {(DOMAIN, device)}, + "identifiers": {(DOMAIN, self.coordinator.data.id)}, "name": "Atag Thermostat", "model": "Atag One", - "sw_version": version, + "sw_version": self.coordinator.data.apiversion, "manufacturer": "Atag", } - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self.coordinator.data.id}-{self._id}" diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index da7e6a14a73..6bafd59ab82 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -37,10 +37,14 @@ async def async_setup_entry(hass, entry, async_add_entities): class AtagThermostat(AtagEntity, ClimateEntity): """Atag climate device.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = list(PRESET_MAP.keys()) + _attr_supported_features = SUPPORT_FLAGS + + def __init__(self, coordinator, atag_id): + """Initialize an Atag climate device.""" + super().__init__(coordinator, atag_id) + self._attr_temperature_unit = coordinator.data.climate.temp_unit @property def hvac_mode(self) -> str | None: @@ -49,22 +53,12 @@ class AtagThermostat(AtagEntity, ClimateEntity): return self.coordinator.data.climate.hvac_mode return None - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return HVAC_MODES - @property def hvac_action(self) -> str | None: """Return the current running hvac operation.""" is_active = self.coordinator.data.climate.status return CURRENT_HVAC_HEAT if is_active else CURRENT_HVAC_IDLE - @property - def temperature_unit(self) -> str | None: - """Return the unit of measurement.""" - return self.coordinator.data.climate.temp_unit - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -81,11 +75,6 @@ class AtagThermostat(AtagEntity, ClimateEntity): preset = self.coordinator.data.climate.preset_mode return PRESET_INVERTED.get(preset) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return list(PRESET_MAP.keys()) - async def async_set_temperature(self, **kwargs) -> None: """Set new target temperature.""" await self.coordinator.data.climate.set_temp(kwargs.get(ATTR_TEMPERATURE)) diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py index 88ccbdc899f..93164bd14bf 100644 --- a/homeassistant/components/atag/sensor.py +++ b/homeassistant/components/atag/sensor.py @@ -36,7 +36,20 @@ class AtagSensor(AtagEntity, SensorEntity): def __init__(self, coordinator, sensor): """Initialize Atag sensor.""" super().__init__(coordinator, SENSORS[sensor]) - self._name = sensor + self._attr_name = sensor + if coordinator.data.report[self._id].sensorclass in [ + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + ]: + self._attr_device_class = coordinator.data.report[self._id].sensorclass + if coordinator.data.report[self._id].measure in [ + PRESSURE_BAR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + PERCENTAGE, + TIME_HOURS, + ]: + self._attr_unit_of_measurement = coordinator.data.report[self._id].measure @property def state(self): @@ -47,26 +60,3 @@ class AtagSensor(AtagEntity, SensorEntity): def icon(self): """Return icon.""" return self.coordinator.data.report[self._id].icon - - @property - def device_class(self): - """Return deviceclass.""" - if self.coordinator.data.report[self._id].sensorclass in [ - DEVICE_CLASS_PRESSURE, - DEVICE_CLASS_TEMPERATURE, - ]: - return self.coordinator.data.report[self._id].sensorclass - return None - - @property - def unit_of_measurement(self): - """Return measure.""" - if self.coordinator.data.report[self._id].measure in [ - PRESSURE_BAR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - PERCENTAGE, - TIME_HOURS, - ]: - return self.coordinator.data.report[self._id].measure - return None diff --git a/homeassistant/components/atag/translations/de.json b/homeassistant/components/atag/translations/de.json index 72e8c69cc26..976faaa370d 100644 --- a/homeassistant/components/atag/translations/de.json +++ b/homeassistant/components/atag/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Ger\u00e4t wurde bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -13,7 +13,7 @@ "host": "Host", "port": "Port" }, - "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her" + "title": "Verbinden mit dem Ger\u00e4t" } } } diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py index dac56edf89d..5fce2abf63e 100644 --- a/homeassistant/components/atag/water_heater.py +++ b/homeassistant/components/atag/water_heater.py @@ -22,15 +22,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AtagWaterHeater(AtagEntity, WaterHeaterEntity): """Representation of an ATAG water heater.""" - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_FLAGS_HEATER - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS + _attr_operation_list = OPERATION_LIST + _attr_supported_features = SUPPORT_FLAGS_HEATER + _attr_temperature_unit = TEMP_CELSIUS @property def current_temperature(self): @@ -43,11 +37,6 @@ class AtagWaterHeater(AtagEntity, WaterHeaterEntity): operation = self.coordinator.data.dhw.current_operation return operation if operation in self.operation_list else STATE_OFF - @property - def operation_list(self): - """List of available operation modes.""" - return OPERATION_LIST - async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if await self.coordinator.data.dhw.set_temp(kwargs.get(ATTR_TEMPERATURE)): diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 43146938961..044c890fc65 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -61,7 +61,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async for outlet in outlets: switches.append(AtenSwitch(dev, mac, outlet.id, outlet.name)) - async_add_entities(switches) + async_add_entities(switches, True) class AtenSwitch(SwitchEntity): @@ -72,48 +72,26 @@ class AtenSwitch(SwitchEntity): def __init__(self, device, mac, outlet, name): """Initialize an ATEN PE switch.""" self._device = device - self._mac = mac self._outlet = outlet - self._name = name or f"Outlet {outlet}" - self._enabled = False - self._outlet_power = 0.0 - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._mac}-{self._outlet}" - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._enabled - - @property - def current_power_w(self) -> float: - """Return the current power usage in W.""" - return self._outlet_power + self._attr_unique_id = f"{mac}-{outlet}" + self._attr_name = name or f"Outlet {outlet}" async def async_turn_on(self, **kwargs): """Turn the switch on.""" await self._device.setOutletStatus(self._outlet, "on") - self._enabled = True + self._attr_is_on = True async def async_turn_off(self, **kwargs): """Turn the switch off.""" await self._device.setOutletStatus(self._outlet, "off") - self._enabled = False + self._attr_is_on = False async def async_update(self): """Process update from entity.""" status = await self._device.displayOutletStatus(self._outlet) if status == "on": - self._enabled = True - self._outlet_power = await self._device.outletPower(self._outlet) - elif status == "off": - self._enabled = False - self._outlet_power = 0.0 + self._attr_is_on = True + self._attr_current_power_w = await self._device.outletPower(self._outlet) + else: + self._attr_is_on = False + self._attr_current_power_w = 0.0 diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 30374dcb220..f48068498c6 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -173,10 +173,10 @@ class AugustData(AugustSubscriberMixin): async def _async_refresh_device_detail_by_ids(self, device_ids_list): await asyncio.gather( - *[ + *( self._async_refresh_device_detail_by_id(device_id) for device_id in device_ids_list - ] + ) ) async def _async_refresh_device_detail_by_id(self, device_id): diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 402a2ccd610..f64b27c7c85 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -61,9 +61,9 @@ class ActivityStream(AugustSubscriberMixin): """Cleanup any debounces.""" for debouncer in self._update_debounce.values(): debouncer.async_cancel() - for house_id in self._schedule_updates: - if self._schedule_updates[house_id] is not None: - self._schedule_updates[house_id]() + for house_id, updater in self._schedule_updates.items(): + if updater is not None: + updater() self._schedule_updates[house_id] = None def get_latest_device_activity(self, device_id, activity_types): @@ -98,10 +98,10 @@ class ActivityStream(AugustSubscriberMixin): async def _async_update_device_activities(self, time): _LOGGER.debug("Start retrieving device activities") await asyncio.gather( - *[ + *( self._update_debounce[house_id].async_call() for house_id in self._house_ids - ] + ) ) self._last_update_time = time diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index e72d4b186a5..27a115a0823 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -112,10 +112,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities.append(AugustDoorBinarySensor(data, "door_open", door)) for doorbell in data.doorbells: - for sensor_type in SENSOR_TYPES_DOORBELL: + for sensor_type, sensor in SENSOR_TYPES_DOORBELL.items(): _LOGGER.debug( "Adding doorbell sensor class %s for %s", - SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS], + sensor[SENSOR_DEVICE_CLASS], doorbell.device_name, ) entities.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) @@ -126,34 +126,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): """Representation of an August Door binary sensor.""" + _attr_device_class = DEVICE_CLASS_DOOR + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" super().__init__(data, device) self._data = data self._sensor_type = sensor_type self._device = device + self._attr_name = f"{device.device_name} Open" + self._attr_unique_id = f"{self._device_id}_open" self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._detail.bridge_is_online - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._detail.door_state == LockDoorStatus.OPEN - - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_DOOR - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._device.device_name} Open" - @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" @@ -173,11 +157,8 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorEntity): if bridge_activity is not None: update_lock_detail_from_activity(self._detail, bridge_activity) - - @property - def unique_id(self) -> str: - """Get the unique of the door open binary sensor.""" - return f"{self._device_id}_open" + self._attr_available = self._detail.bridge_is_online + self._attr_is_on = self._detail.door_state == LockDoorStatus.OPEN class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): @@ -189,36 +170,18 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): self._check_for_off_update_listener = None self._data = data self._sensor_type = sensor_type - self._device = device - self._state = None - self._available = False + self._attr_device_class = self._sensor_config[SENSOR_DEVICE_CLASS] + self._attr_name = f"{device.device_name} {self._sensor_config[SENSOR_NAME]}" + self._attr_unique_id = ( + f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}" + ) self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - @property def _sensor_config(self): """Return the config for the sensor.""" return SENSOR_TYPES_DOORBELL[self._sensor_type] - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._sensor_config[SENSOR_DEVICE_CLASS] - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._device.device_name} {self._sensor_config[SENSOR_NAME]}" - @property def _state_provider(self): """Return the state provider for the binary sensor.""" @@ -233,19 +196,19 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - self._state = self._state_provider(self._data, self._detail) + self._attr_is_on = self._state_provider(self._data, self._detail) if self._is_time_based: - self._available = _retrieve_online_state(self._data, self._detail) + self._attr_available = _retrieve_online_state(self._data, self._detail) self._schedule_update_to_recheck_turn_off_sensor() else: - self._available = True + self._attr_available = True def _schedule_update_to_recheck_turn_off_sensor(self): """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._state: + if not self.is_on: return # self.hass is only available after setup is completed @@ -258,7 +221,7 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Timer callback for sensor update.""" self._check_for_off_update_listener = None self._update_from_data() - if not self._state: + if not self.is_on: self.async_write_ha_state() self._check_for_off_update_listener = async_call_later( @@ -277,8 +240,3 @@ class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorEntity): """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" self._schedule_update_to_recheck_turn_off_sensor() await super().async_added_to_hass() - - @property - def unique_id(self) -> str: - """Get the unique id of the doorbell sensor.""" - return f"{self._device_id}_{self._sensor_config[SENSOR_NAME].lower()}" diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index daaa7624aa3..6bb47a06eee 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -35,11 +35,8 @@ class AugustCamera(AugustEntityMixin, Camera): self._session = session self._image_url = None self._image_content = None - - @property - def name(self): - """Return the name of this device.""" - return f"{self._device.device_name} Camera" + self._attr_name = f"{device.device_name} Camera" + self._attr_unique_id = f"{self._device_id:s}_camera" @property def is_recording(self): @@ -81,8 +78,3 @@ class AugustCamera(AugustEntityMixin, Camera): self._session, timeout=self._timeout ) return self._image_content - - @property - def unique_id(self) -> str: - """Get the unique id of the camera.""" - return f"{self._device_id:s}_camera" diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index b2a93948449..af8c858a1d4 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -11,16 +11,21 @@ DEVICE_TYPES = ["keypad", "lock", "camera", "doorbell", "door", "bell"] class AugustEntityMixin(Entity): """Base implementation for August device.""" + _attr_should_poll = False + def __init__(self, data, device): """Initialize an August device.""" super().__init__() self._data = data self._device = device - - @property - def should_poll(self): - """Return False, updates are controlled via the hub.""" - return False + self._attr_device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "name": device.device_name, + "manufacturer": MANUFACTURER, + "sw_version": self._detail.firmware_version, + "model": self._detail.model, + "suggested_area": _remove_device_types(device.device_name, DEVICE_TYPES), + } @property def _device_id(self): @@ -30,19 +35,6 @@ class AugustEntityMixin(Entity): def _detail(self): return self._data.get_device_detail(self._device.device_id) - @property - def device_info(self): - """Return the device_info of the device.""" - name = self._device.device_name - return { - "identifiers": {(DOMAIN, self._device_id)}, - "name": name, - "manufacturer": MANUFACTURER, - "sw_version": self._detail.firmware_version, - "model": self._detail.model, - "suggested_area": _remove_device_types(name, DEVICE_TYPES), - } - @callback def _update_from_data_and_write_state(self): self._update_from_data() diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 6e4ee7e6f5c..5f4fe85bc71 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,6 +1,7 @@ """Support for August lock.""" import logging +from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType from yalexs.lock import LockStatus from yalexs.util import update_lock_detail_from_activity @@ -9,12 +10,15 @@ from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.util.dt as dt_util from .const import DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) +LOCK_JAMMED_ERR = 531 + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" @@ -31,8 +35,8 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): self._data = data self._device = device self._lock_status = None - self._changed_by = None - self._available = False + self._attr_name = device.device_name + self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() async def async_lock(self, **kwargs): @@ -44,9 +48,17 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): await self._call_lock_operation(self._data.async_unlock) async def _call_lock_operation(self, lock_operation): - activities = await lock_operation(self._device_id) - for lock_activity in activities: - update_lock_detail_from_activity(self._detail, lock_activity) + try: + activities = await lock_operation(self._device_id) + except ClientResponseError as err: + if err.status == LOCK_JAMMED_ERR: + self._detail.lock_status = LockStatus.JAMMED + self._detail.lock_status_datetime = dt_util.utcnow() + else: + raise + else: + for lock_activity in activities: + update_lock_detail_from_activity(self._detail, lock_activity) if self._update_lock_status_from_detail(): _LOGGER.debug( @@ -56,7 +68,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): self._data.async_signal_device_id_update(self._device_id) def _update_lock_status_from_detail(self): - self._available = self._detail.bridge_is_online + self._attr_available = self._detail.bridge_is_online if self._lock_status != self._detail.lock_status: self._lock_status = self._detail.lock_status @@ -72,7 +84,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): ) if lock_activity is not None: - self._changed_by = lock_activity.operated_by + self._attr_changed_by = lock_activity.operated_by update_lock_detail_from_activity(self._detail, lock_activity) # If the source is pubnub the lock must be online since its a live update if lock_activity.source == SOURCE_PUBNUB: @@ -86,38 +98,22 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): update_lock_detail_from_activity(self._detail, bridge_activity) self._update_lock_status_from_detail() - - @property - def name(self): - """Return the name of this device.""" - return self._device.device_name - - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def is_locked(self): - """Return true if device is on.""" if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: - return None - return self._lock_status is LockStatus.LOCKED + self._attr_is_locked = None + else: + self._attr_is_locked = self._lock_status is LockStatus.LOCKED - @property - def changed_by(self): - """Last change triggered by.""" - return self._changed_by - - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level} + 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 is LockStatus.UNLOCKING + self._attr_extra_state_attributes = { + ATTR_BATTERY_LEVEL: self._detail.battery_level + } if self._detail.keypad is not None: - attributes["keypad_battery_level"] = self._detail.keypad.battery_level - - return attributes + self._attr_extra_state_attributes[ + "keypad_battery_level" + ] = self._detail.keypad.battery_level async def async_added_to_hass(self): """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" @@ -128,9 +124,4 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return if ATTR_CHANGED_BY in last_state.attributes: - self._changed_by = last_state.attributes[ATTR_CHANGED_BY] - - @property - def unique_id(self) -> str: - """Get the unique id of the lock.""" - return f"{self._device_id:s}_lock" + self._attr_changed_by = last_state.attributes[ATTR_CHANGED_BY] diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e966338f287..74caa4b4a78 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["yalexs==1.1.11"], + "requirements": ["yalexs==1.1.13"], "codeowners": ["@bdraco"], "dhcp": [ { diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 1d973a83fc3..a174964f349 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -125,25 +125,13 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): super().__init__(data, device) self._data = data self._device = device - self._state = None self._operated_remote = None self._operated_keypad = None self._operated_autorelock = None self._operated_time = None - self._available = False self._entity_picture = None self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - @property def name(self): """Return the name of the sensor.""" @@ -156,9 +144,9 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): self._device_id, {ActivityType.LOCK_OPERATION} ) - self._available = True + self._attr_available = True if lock_activity is not None: - self._state = lock_activity.operated_by + self._attr_state = lock_activity.operated_by self._operated_remote = lock_activity.operated_remote self._operated_keypad = lock_activity.operated_keypad self._operated_autorelock = lock_activity.operated_autorelock @@ -195,7 +183,7 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): if not last_state or last_state.state == STATE_UNAVAILABLE: return - self._state = last_state.state + self._attr_state = last_state.state if ATTR_ENTITY_PICTURE in last_state.attributes: self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_state.attributes: @@ -219,54 +207,24 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): class AugustBatterySensor(AugustEntityMixin, SensorEntity): """Representation of an August sensor.""" + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_unit_of_measurement = PERCENTAGE + def __init__(self, data, sensor_type, device, old_device): """Initialize the sensor.""" super().__init__(data, device) - self._data = data self._sensor_type = sensor_type - self._device = device self._old_device = old_device - self._state = None - self._available = False + self._attr_name = f"{device.device_name} Battery" + self._attr_unique_id = f"{self._device_id}_{sensor_type}" self._update_from_data() - @property - def available(self): - """Return the availability of this sensor.""" - return self._available - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return PERCENTAGE - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_BATTERY - - @property - def name(self): - """Return the name of the sensor.""" - device_name = self._device.device_name - return f"{device_name} Battery" - @callback def _update_from_data(self): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - self._state = state_provider(self._detail) - self._available = self._state is not None - - @property - def unique_id(self) -> str: - """Get the unique id of the device sensor.""" - return f"{self._device_id}_{self._sensor_type}" + self._attr_state = state_provider(self._detail) + self._attr_available = self._attr_state is not None @property def old_unique_id(self) -> str: diff --git a/homeassistant/components/august/translations/de.json b/homeassistant/components/august/translations/de.json index d2e08a5377c..7f52aa083e7 100644 --- a/homeassistant/components/august/translations/de.json +++ b/homeassistant/components/august/translations/de.json @@ -30,7 +30,7 @@ "data": { "code": "Verifizierungs-Code" }, - "description": "Bitte \u00fcberpr\u00fcfen Sie Ihre {login_method} ({username}) und geben Sie den Best\u00e4tigungscode ein", + "description": "Bitte \u00fcberpr\u00fcfe deine {login_method} ({username}) und gib den Best\u00e4tigungscode ein", "title": "Zwei-Faktor-Authentifizierung" } } diff --git a/homeassistant/components/august/translations/he.json b/homeassistant/components/august/translations/he.json index 5fb689b2562..aeb3c6a9f14 100644 --- a/homeassistant/components/august/translations/he.json +++ b/homeassistant/components/august/translations/he.json @@ -25,7 +25,7 @@ } }, "validation": { - "description": "\u05d0\u05e0\u05d0 \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea {login_method} \u05e9\u05dc\u05da ({\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9}) \u05d5\u05d4\u05d6\u05df \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05d4\u05dc\u05df" + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea {login_method} ( {username} ) \u05d5\u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05d4\u05dc\u05df" } } } diff --git a/homeassistant/components/august/translations/hu.json b/homeassistant/components/august/translations/hu.json index f95d180b4b5..fec6ad93b26 100644 --- a/homeassistant/components/august/translations/hu.json +++ b/homeassistant/components/august/translations/hu.json @@ -23,6 +23,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Ha a bejelentkez\u00e9si m\u00f3dszer \u201ee-mail\u201d, akkor a felhaszn\u00e1l\u00f3n\u00e9v az e-mail c\u00edm. Ha a bejelentkez\u00e9si m\u00f3dszer \u201etelefon\u201d, akkor a felhaszn\u00e1l\u00f3n\u00e9v a \u201e+ NNNNNNNNNN\u201d form\u00e1tum\u00fa telefonsz\u00e1m.", "title": "August fi\u00f3k be\u00e1ll\u00edt\u00e1sa" }, "validation": { diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py index faccefda500..576ccc1275b 100644 --- a/homeassistant/components/aurora/__init__.py +++ b/homeassistant/components/aurora/__init__.py @@ -123,6 +123,8 @@ class AuroraDataUpdateCoordinator(DataUpdateCoordinator): class AuroraEntity(CoordinatorEntity): """Implementation of the base Aurora Entity.""" + _attr_extra_state_attributes = {"attribution": ATTRIBUTION} + def __init__( self, coordinator: AuroraDataUpdateCoordinator, @@ -133,35 +135,15 @@ class AuroraEntity(CoordinatorEntity): super().__init__(coordinator=coordinator) - self._name = name - self._unique_id = f"{self.coordinator.latitude}_{self.coordinator.longitude}" - self._icon = icon - - @property - def unique_id(self): - """Define the unique id based on the latitude and longitude.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"attribution": ATTRIBUTION} - - @property - def icon(self): - """Return the icon for the sensor.""" - return self._icon + self._attr_name = name + self._attr_unique_id = f"{coordinator.latitude}_{coordinator.longitude}" + self._attr_icon = icon @property def device_info(self): """Define the device based on name.""" return { - ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)}, + ATTR_IDENTIFIERS: {(DOMAIN, self.unique_id)}, ATTR_NAME: self.coordinator.name, ATTR_MANUFACTURER: "NOAA", ATTR_MODEL: "Aurora Visibility Sensor", diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index d7024cc630a..76be6ca97f8 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -22,12 +22,9 @@ async def async_setup_entry(hass, entry, async_add_entries): class AuroraSensor(AuroraEntity, SensorEntity): """Implementation of an aurora sensor.""" + _attr_unit_of_measurement = PERCENTAGE + @property def state(self): """Return % chance the aurora is visible.""" return self.coordinator.data - - @property - def unit_of_measurement(self): - """Return the unit of measure.""" - return PERCENTAGE diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index cd4a71d1b31..9c798b8e6d4 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -51,32 +51,13 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): """Representation of a Sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_unit_of_measurement = POWER_WATT + _attr_device_class = DEVICE_CLASS_POWER def __init__(self, client, name, typename): """Initialize the sensor.""" - self._name = f"{name} {typename}" + self._attr_name = f"{name} {typename}" self.client = client - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return POWER_WATT - - @property - def device_class(self): - """Return the device class.""" - return DEVICE_CLASS_POWER def update(self): """Fetch new state data for the sensor. @@ -87,8 +68,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): self.client.connect() # read ADC channel 3 (grid power output) power_watts = self.client.measure(3, True) - self._state = round(power_watts, 1) - # _LOGGER.debug("Got reading %fW" % self._state) + self._attr_state = round(power_watts, 1) except AuroraError as error: # aurorapy does not have different exceptions (yet) for dealing # with timeout vs other comms errors. @@ -102,7 +82,7 @@ class AuroraABBSolarPVMonitorSensor(SensorEntity): _LOGGER.debug("No response from inverter (could be dark)") else: raise error - self._state = None + self._attr_state = None finally: if self.client.serline.isOpen(): self.client.close() diff --git a/homeassistant/components/auth/translations/he.json b/homeassistant/components/auth/translations/he.json index bc1826d4d79..6bbf472a14b 100644 --- a/homeassistant/components/auth/translations/he.json +++ b/homeassistant/components/auth/translations/he.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." + "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05ea\u05e8\u05d0\u05d5\u05ea \u05d6\u05de\u05d9\u05e0\u05d9\u05dd." }, "error": { "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." }, "step": { "init": { - "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify", + "description": "\u05e0\u05d0 \u05dc\u05d1\u05d7\u05d5\u05e8 \u05d0\u05d7\u05d3 \u05de\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 \u05d4\u05d4\u05d5\u05d3\u05e2\u05d5\u05ea:", "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify" }, "setup": { - "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:", + "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea ** Notify. {notify_service} **. \u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4:", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4" } }, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 1733f272229..a0ff4930b51 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -272,6 +272,8 @@ async def async_setup(hass, config): class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" + _attr_should_poll = False + def __init__( self, automation_id, @@ -287,8 +289,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): trace_config, ): """Initialize an automation entity.""" - self._id = automation_id - self._name = name + self._attr_name = name self._trigger_config = trigger_config self._async_detach_triggers = None self._cond_func = cond_func @@ -304,21 +305,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._raw_config = raw_config self._blueprint_inputs = blueprint_inputs self._trace_config = trace_config - - @property - def name(self): - """Name of the automation.""" - return self._name - - @property - def unique_id(self): - """Return unique ID.""" - return self._id - - @property - def should_poll(self): - """No polling needed for automation entities.""" - return False + self._attr_unique_id = automation_id @property def extra_state_attributes(self): @@ -330,8 +317,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): } if self.action_script.supports_max: attrs[ATTR_MAX] = self.action_script.max_runs - if self._id is not None: - attrs[CONF_ID] = self._id + if self.unique_id is not None: + attrs[CONF_ID] = self.unique_id return attrs @property @@ -458,15 +445,19 @@ class AutomationEntity(ToggleEntity, RestoreEntity): trigger_context, self._trace_config, ) as automation_trace: + this = None + state = self.hass.states.get(self.entity_id) + if state: + this = state.as_dict() + variables = {"this": this, **(run_variables or {})} if self._variables: try: - variables = self._variables.async_render(self.hass, run_variables) + variables = self._variables.async_render(self.hass, variables) except template.TemplateError as err: self._logger.error("Error rendering variables: %s", err) automation_trace.set_error(err) return - else: - variables = run_variables + # Prepare tracing the automation automation_trace.set_trace(trace_get()) @@ -496,7 +487,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self.async_set_context(trigger_context) event_data = { - ATTR_NAME: self._name, + ATTR_NAME: self.name, ATTR_ENTITY_ID: self.entity_id, } if "trigger" in variables and "description" in variables["trigger"]: @@ -580,13 +571,20 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Set up the triggers.""" def log_cb(level, msg, **kwargs): - self._logger.log(level, "%s %s", msg, self._name, **kwargs) + self._logger.log(level, "%s %s", msg, self.name, **kwargs) - variables = None + this = None + self.async_write_ha_state() + state = self.hass.states.get(self.entity_id) + if state: + this = state.as_dict() + variables = {"this": this} if self._trigger_variables: try: variables = self._trigger_variables.async_render( - self.hass, None, limited=True + self.hass, + variables, + limited=True, ) except template.TemplateError as err: self._logger.error("Error rendering trigger variables: %s", err) @@ -597,7 +595,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._trigger_config, self.async_trigger, DOMAIN, - self._name, + str(self.name), log_cb, home_assistant_start, variables, diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index e28fa5c477f..83076778b91 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -75,10 +75,10 @@ async def async_validate_config_item(hass, config, full_config=None): if CONF_CONDITION in config: config[CONF_CONDITION] = await asyncio.gather( - *[ + *( async_validate_condition_config(hass, cond) for cond in config[CONF_CONDITION] - ] + ) ) config[CONF_ACTION] = await script.async_validate_actions_config( diff --git a/homeassistant/components/automation/translations/he.json b/homeassistant/components/automation/translations/he.json index 6e4decfce9a..0b94cedbebd 100644 --- a/homeassistant/components/automation/translations/he.json +++ b/homeassistant/components/automation/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05d0\u05d5\u05d8\u05d5\u05de\u05e6\u05d9\u05d4" diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index eca020f6cd0..d1df7ba3e46 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -30,32 +30,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AveaLight(LightEntity): """Representation of an Avea.""" + _attr_supported_features = SUPPORT_AVEA + def __init__(self, light): """Initialize an AveaLight.""" self._light = light - self._name = light.name - self._state = None - self._brightness = light.brightness - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_AVEA - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_name = light.name + self._attr_brightness = light.brightness def turn_on(self, **kwargs): """Instruct the light to turn on.""" @@ -80,8 +61,5 @@ class AveaLight(LightEntity): """ brightness = self._light.get_brightness() if brightness is not None: - if brightness == 0: - self._state = False - else: - self._state = True - self._brightness = round(255 * (brightness / 4095)) + self._attr_is_on = brightness != 0 + self._attr_brightness = round(255 * (brightness / 4095)) diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 0d242b952dd..fcc780f77bc 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -65,49 +65,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class AvionLight(LightEntity): """Representation of an Avion light.""" + _attr_supported_features = SUPPORT_AVION_LED + _attr_should_poll = False + _attr_assumed_state = True + def __init__(self, device): """Initialize the light.""" - self._name = device.name - self._address = device.mac - self._brightness = 255 - self._state = False + self._attr_name = device.name + self._attr_unique_id = device.mac + self._attr_brightness = 255 self._switch = device - @property - def unique_id(self): - """Return the ID of this light.""" - return self._address - - @property - def name(self): - """Return the name of the device if any.""" - return self._name - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_AVION_LED - - @property - def should_poll(self): - """Don't poll.""" - return False - - @property - def assumed_state(self): - """We can't read the actual state, so assume it matches.""" - return True - def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" avion = importlib.import_module("avion") @@ -130,12 +98,12 @@ class AvionLight(LightEntity): brightness = kwargs.get(ATTR_BRIGHTNESS) if brightness is not None: - self._brightness = brightness + self._attr_brightness = brightness self.set_state(self.brightness) - self._state = True + self._attr_is_on = True def turn_off(self, **kwargs): """Turn the specified or all lights off.""" self.set_state(0) - self._state = False + self._attr_is_on = False diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 6af2850ea31..8199c3881c9 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -64,7 +64,7 @@ class AwairDataUpdateCoordinator(DataUpdateCoordinator): user = await self._awair.user() devices = await user.devices() results = await gather( - *[self._fetch_air_data(device) for device in devices] + *(self._fetch_air_data(device) for device in devices) ) return {result.device.uuid: result for result in results} except AuthError as err: diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index 2853ef9dd6c..1841a167a50 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, + SOUND_PRESSURE_WEIGHTED_DBA, TEMP_CELSIUS, ) @@ -72,7 +73,7 @@ SENSOR_TYPES = { API_SPL_A: { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:ear-hearing", - ATTR_UNIT: "dBa", + ATTR_UNIT: SOUND_PRESSURE_WEIGHTED_DBA, ATTR_LABEL: "Sound level", ATTR_UNIQUE_ID: "sound_level", }, diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index f1a57eec33c..90eacf47965 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -12,7 +12,7 @@ from axis.streammanager import SIGNAL_PLAYING, STATE_STOPPED from homeassistant.components import mqtt from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mqtt.models import Message +from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -195,7 +195,7 @@ class AxisNetworkDevice: ) @callback - def mqtt_message(self, message: Message) -> None: + def mqtt_message(self, message: ReceiveMessage) -> None: """Receive Axis MQTT message.""" self.disconnect_from_stream() @@ -226,12 +226,12 @@ class AxisNetworkDevice: async def start_platforms(): await asyncio.gather( - *[ + *( self.hass.config_entries.async_forward_entry_setup( self.config_entry, platform ) for platform in PLATFORMS - ] + ) ) if self.option_events: self.api.stream.connection_status_callback.append( diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 5e3971adcc4..39307ac41df 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -55,40 +55,24 @@ class AzureDevOpsEntity(Entity): def __init__(self, organization: str, project: str, name: str, icon: str) -> None: """Initialize the Azure DevOps entity.""" - self._name = name - self._icon = icon - self._available = True + self._attr_name = name + self._attr_icon = icon self.organization = organization self.project = project - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - async def async_update(self) -> None: """Update Azure DevOps entity.""" if await self._azure_devops_update(): - self._available = True + self._attr_available = True else: - if self._available: + if self._attr_available: _LOGGER.debug( "An error occurred while updating Azure DevOps sensor", exc_info=True, ) - self._available = False + self._attr_available = False - async def _azure_devops_update(self) -> None: + async def _azure_devops_update(self) -> bool: """Update Azure DevOps entity.""" raise NotImplementedError() @@ -101,7 +85,7 @@ class AzureDevOpsDeviceEntity(AzureDevOpsEntity): """Return device information about this Azure DevOps instance.""" return { "identifiers": { - ( + ( # type: ignore DOMAIN, self.organization, self.project, diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 170cd244884..d7589cf5014 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -71,38 +71,14 @@ class AzureDevOpsSensor(AzureDevOpsDeviceEntity, SensorEntity): unit_of_measurement: str = "", ) -> None: """Initialize Azure DevOps sensor.""" - self._state = None - self._attributes = None - self._available = False - self._unit_of_measurement = unit_of_measurement - self.measurement = measurement + self._attr_unit_of_measurement = unit_of_measurement self.client = client self.organization = organization self.project = project - self.key = key + self._attr_unique_id = "_".join([organization, key]) super().__init__(organization, project, name, icon) - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return "_".join([self.organization, self.key]) - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self) -> object: - """Return the attributes of the sensor.""" - return self._attributes - - @property - def unit_of_measurement(self) -> str: - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): """Defines a Azure DevOps card count sensor.""" @@ -129,10 +105,10 @@ class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): ) except aiohttp.ClientError as exception: _LOGGER.warning(exception) - self._available = False + self._attr_available = False return False - self._state = build.build_number - self._attributes = { + self._attr_state = build.build_number + self._attr_extra_state_attributes = { "definition_id": build.definition.id, "definition_name": build.definition.name, "id": build.id, @@ -146,5 +122,5 @@ class AzureDevOpsLatestBuildSensor(AzureDevOpsSensor): "start_time": build.start_time, "finish_time": build.finish_time, } - self._available = True + self._attr_available = True return True diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6879e278bab..41654973ffe 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -129,13 +129,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BayesianBinarySensor(BinarySensorEntity): """Representation of a Bayesian sensor.""" + _attr_should_poll = False + def __init__(self, name, prior, observations, probability_threshold, device_class): """Initialize the Bayesian sensor.""" - self._name = name + self._attr_name = name self._observations = observations self._probability_threshold = probability_threshold - self._device_class = device_class - self._deviation = False + self._attr_device_class = device_class + self._attr_is_on = False self._callbacks = [] self.prior = prior @@ -238,12 +240,12 @@ class BayesianBinarySensor(BinarySensorEntity): self.current_observations.update(self._initialize_current_observations()) self.probability = self._calculate_new_probability() - self._deviation = bool(self.probability >= self._probability_threshold) + self._attr_is_on = bool(self.probability >= self._probability_threshold) @callback def _recalculate_and_write_state(self): self.probability = self._calculate_new_probability() - self._deviation = bool(self.probability >= self._probability_threshold) + self._attr_is_on = bool(self.probability >= self._probability_threshold) self.async_write_ha_state() def _initialize_current_observations(self): @@ -363,30 +365,9 @@ class BayesianBinarySensor(BinarySensorEntity): except ConditionError: return False - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._deviation - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" - attr_observations_list = [ obs.copy() for obs in self.current_observations.values() if obs is not None ] diff --git a/homeassistant/components/bbb_gpio/binary_sensor.py b/homeassistant/components/bbb_gpio/binary_sensor.py index c772cf86f00..5fcaccd674e 100644 --- a/homeassistant/components/bbb_gpio/binary_sensor.py +++ b/homeassistant/components/bbb_gpio/binary_sensor.py @@ -43,10 +43,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BBBGPIOBinarySensor(BinarySensorEntity): """Representation of a binary sensor that uses Beaglebone Black GPIO.""" + _attr_should_poll = False + def __init__(self, pin, params): """Initialize the Beaglebone Black binary sensor.""" self._pin = pin - self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._attr_name = params[CONF_NAME] or DEVICE_DEFAULT_NAME self._bouncetime = params[CONF_BOUNCETIME] self._pull_mode = params[CONF_PULL_MODE] self._invert_logic = params[CONF_INVERT_LOGIC] @@ -62,16 +64,6 @@ class BBBGPIOBinarySensor(BinarySensorEntity): bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the entity.""" return self._state != self._invert_logic diff --git a/homeassistant/components/bbb_gpio/switch.py b/homeassistant/components/bbb_gpio/switch.py index 03a9065a15b..3bed1d7db13 100644 --- a/homeassistant/components/bbb_gpio/switch.py +++ b/homeassistant/components/bbb_gpio/switch.py @@ -37,10 +37,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BBBGPIOSwitch(ToggleEntity): """Representation of a BeagleBone Black GPIO.""" + _attr_should_poll = False + def __init__(self, pin, params): """Initialize the pin.""" self._pin = pin - self._name = params[CONF_NAME] or DEVICE_DEFAULT_NAME + self._attr_name = params[CONF_NAME] or DEVICE_DEFAULT_NAME self._state = params[CONF_INITIAL] self._invert_logic = params[CONF_INVERT_LOGIC] @@ -52,17 +54,7 @@ class BBBGPIOSwitch(ToggleEntity): bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1) @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index 5256c2a61a0..b0ace5fa675 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -88,40 +88,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BboxUptimeSensor(SensorEntity): """Bbox uptime sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + _attr_device_class = DEVICE_CLASS_TIMESTAMP + def __init__(self, bbox_data, sensor_type, name): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] + self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self._attr_icon = SENSOR_TYPES[sensor_type][2] self.bbox_data = bbox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_TIMESTAMP def update(self): """Get the latest data from Bbox and update the state.""" @@ -129,60 +104,39 @@ class BboxUptimeSensor(SensorEntity): uptime = utcnow() - timedelta( seconds=self.bbox_data.router_infos["device"]["uptime"] ) - self._state = uptime.replace(microsecond=0).isoformat() + self._attr_state = uptime.replace(microsecond=0).isoformat() class BboxSensor(SensorEntity): """Implementation of a Bbox sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__(self, bbox_data, sensor_type, name): """Initialize the sensor.""" - self.client_name = name self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_icon = SENSOR_TYPES[sensor_type][2] self.bbox_data = bbox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data from Bbox and update the state.""" self.bbox_data.update() if self.type == "down_max_bandwidth": - self._state = round(self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2) + self._attr_state = round( + self.bbox_data.data["rx"]["maxBandwidth"] / 1000, 2 + ) elif self.type == "up_max_bandwidth": - self._state = round(self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2) + self._attr_state = round( + self.bbox_data.data["tx"]["maxBandwidth"] / 1000, 2 + ) elif self.type == "current_down_bandwidth": - self._state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) + self._attr_state = round(self.bbox_data.data["rx"]["bandwidth"] / 1000, 2) elif self.type == "current_up_bandwidth": - self._state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) + self._attr_state = round(self.bbox_data.data["tx"]["bandwidth"] / 1000, 2) elif self.type == "number_of_reboots": - self._state = self.bbox_data.router_infos["device"]["numberofboots"] + self._attr_state = self.bbox_data.router_infos["device"]["numberofboots"] class BboxData: diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 9bf935f3c4f..2ed6b71be41 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -61,44 +61,19 @@ class BeewiSmartclimSensor(SensorEntity): def __init__(self, poller, name, mac, device, unit): """Initialize the sensor.""" self._poller = poller - self._name = name - self._mac = mac + self._attr_name = name self._device = device - self._unit = unit - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor. State is returned in Celsius.""" - return self._state - - @property - def device_class(self): - """Device class of this entity.""" - return self._device - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._mac}_{self._device}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._attr_unit_of_measurement = unit + self._attr_device_class = self._device + self._attr_unique_id = f"{mac}_{device}" def update(self): """Fetch new state data from the poller.""" self._poller.update_sensor() - self._state = None + self._attr_state = None if self._device == DEVICE_CLASS_TEMPERATURE: - self._state = self._poller.get_temperature() + self._attr_state = self._poller.get_temperature() if self._device == DEVICE_CLASS_HUMIDITY: - self._state = self._poller.get_humidity() + self._attr_state = self._poller.get_humidity() if self._device == DEVICE_CLASS_BATTERY: - self._state = self._poller.get_battery() + self._attr_state = self._poller.get_battery() diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py index 5b708ae2630..8a1f8c60ccf 100644 --- a/homeassistant/components/bh1750/sensor.py +++ b/homeassistant/components/bh1750/sensor.py @@ -96,42 +96,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BH1750Sensor(SensorEntity): """Implementation of the BH1750 sensor.""" + _attr_device_class = DEVICE_CLASS_ILLUMINANCE + def __init__(self, bh1750_sensor, name, unit, multiplier=1.0): """Initialize the sensor.""" - self._name = name - self._unit_of_measurement = unit + self._attr_name = name + self._attr_unit_of_measurement = unit self._multiplier = multiplier self.bh1750_sensor = bh1750_sensor - if self.bh1750_sensor.light_level >= 0: - self._state = int(round(self.bh1750_sensor.light_level)) - else: - self._state = None - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def state(self) -> int: - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_ILLUMINANCE async def async_update(self): """Get the latest data from the BH1750 and update the states.""" await self.hass.async_add_executor_job(self.bh1750_sensor.update) if self.bh1750_sensor.sample_ok and self.bh1750_sensor.light_level >= 0: - self._state = int(round(self.bh1750_sensor.light_level * self._multiplier)) + self._attr_state = int( + round(self.bh1750_sensor.light_level * self._multiplier) + ) else: _LOGGER.warning( "Bad Update of sensor.%s: %s", self.name, self.bh1750_sensor.light_level diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index ff97e9af601..2bd5de34d51 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,6 +1,7 @@ """Component to interface with binary sensors.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -14,7 +15,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType @@ -149,9 +150,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class BinarySensorEntityDescription(EntityDescription): + """A class that describes binary sensor entities.""" + + class BinarySensorEntity(Entity): """Represent a binary sensor.""" + entity_description: BinarySensorEntityDescription _attr_is_on: bool | None = None _attr_state: None = None diff --git a/homeassistant/components/binary_sensor/translations/ar.json b/homeassistant/components/binary_sensor/translations/ar.json index 7782421ef1c..0d835aea3f3 100644 --- a/homeassistant/components/binary_sensor/translations/ar.json +++ b/homeassistant/components/binary_sensor/translations/ar.json @@ -32,6 +32,9 @@ "off": "\u0637\u0628\u064a\u0639\u064a", "on": "\u062d\u0627\u0631" }, + "light": { + "on": "\u062a\u0645 \u0627\u0644\u0643\u0634\u0641 \u0639\u0646 \u0627\u0644\u0636\u0648\u0621" + }, "lock": { "off": "\u0645\u0642\u0641\u0644", "on": "\u063a\u064a\u0631 \u0645\u0642\u0641\u0644" diff --git a/homeassistant/components/binary_sensor/translations/de.json b/homeassistant/components/binary_sensor/translations/de.json index a78befb7965..a2ef817bedb 100644 --- a/homeassistant/components/binary_sensor/translations/de.json +++ b/homeassistant/components/binary_sensor/translations/de.json @@ -139,8 +139,8 @@ "on": "Nass" }, "motion": { - "off": "Ruhig", - "on": "Bewegung erkannt" + "off": "Normal", + "on": "Erkannt" }, "moving": { "off": "Bewegt sich nicht", @@ -171,16 +171,16 @@ "on": "Unsicher" }, "smoke": { - "off": "OK", - "on": "Rauch erkannt" + "off": "Normal", + "on": "Erkannt" }, "sound": { - "off": "Stille", - "on": "Ger\u00e4usch erkannt" + "off": "Normal", + "on": "Erkannt" }, "vibration": { "off": "Normal", - "on": "Vibration" + "on": "Erkannt" }, "window": { "off": "Geschlossen", diff --git a/homeassistant/components/binary_sensor/translations/he.json b/homeassistant/components/binary_sensor/translations/he.json index b7375f3175b..c345b1a94ce 100644 --- a/homeassistant/components/binary_sensor/translations/he.json +++ b/homeassistant/components/binary_sensor/translations/he.json @@ -16,7 +16,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" }, "battery": { "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", @@ -27,27 +27,27 @@ "on": "\u05e0\u05d8\u05e2\u05df" }, "cold": { - "off": "\u05e8\u05d2\u05d9\u05dc", - "on": "\u05e7\u05b7\u05e8" + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", + "on": "\u05e7\u05e8" }, "connectivity": { "off": "\u05de\u05e0\u05d5\u05ea\u05e7", "on": "\u05de\u05d7\u05d5\u05d1\u05e8" }, "door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", - "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" }, "garage_door": { - "off": "\u05e1\u05d2\u05d5\u05e8\u05d4", - "on": "\u05e4\u05ea\u05d5\u05d7\u05d4" + "off": "\u05e1\u05d2\u05d5\u05e8", + "on": "\u05e4\u05ea\u05d5\u05d7" }, "gas": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "heat": { - "off": "\u05e8\u05d2\u05d9\u05dc", + "off": "\u05e0\u05d5\u05e8\u05de\u05dc\u05d9", "on": "\u05d7\u05dd" }, "light": { @@ -56,7 +56,7 @@ }, "lock": { "off": "\u05e0\u05e2\u05d5\u05dc", - "on": "\u05dc\u05d0 \u05e0\u05e2\u05d5\u05dc" + "on": "\u05e4\u05ea\u05d5\u05d7" }, "moisture": { "off": "\u05d9\u05d1\u05e9", @@ -82,8 +82,8 @@ "off": "\u05de\u05e0\u05d5\u05ea\u05e7" }, "presence": { - "off": "\u05dc\u05d0 \u05e0\u05d5\u05db\u05d7", - "on": "\u05e0\u05d5\u05db\u05d7" + "off": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", + "on": "\u05d1\u05d1\u05d9\u05ea" }, "problem": { "off": "\u05ea\u05e7\u05d9\u05df", @@ -95,15 +95,15 @@ }, "smoke": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "sound": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "vibration": { "off": "\u05e0\u05e7\u05d9", - "on": "\u05d0\u05d5\u05ea\u05e8" + "on": "\u05d6\u05d5\u05d4\u05d4" }, "window": { "off": "\u05e1\u05d2\u05d5\u05e8", diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index 4acce03d6fa..d11c2a2b726 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -79,39 +79,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BitcoinSensor(SensorEntity): """Representation of a Bitcoin sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + _attr_icon = ICON + def __init__(self, data, option_type, currency): """Initialize the sensor.""" self.data = data - self._name = OPTION_TYPES[option_type][0] - self._unit_of_measurement = OPTION_TYPES[option_type][1] + self._attr_name = OPTION_TYPES[option_type][0] + self._attr_unit_of_measurement = OPTION_TYPES[option_type][1] self._currency = currency self.type = option_type - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest data and updates the states.""" @@ -120,48 +97,48 @@ class BitcoinSensor(SensorEntity): ticker = self.data.ticker if self.type == "exchangerate": - self._state = ticker[self._currency].p15min - self._unit_of_measurement = self._currency + self._attr_state = ticker[self._currency].p15min + self._attr_unit_of_measurement = self._currency elif self.type == "trade_volume_btc": - self._state = f"{stats.trade_volume_btc:.1f}" + self._attr_state = f"{stats.trade_volume_btc:.1f}" elif self.type == "miners_revenue_usd": - self._state = f"{stats.miners_revenue_usd:.0f}" + self._attr_state = f"{stats.miners_revenue_usd:.0f}" elif self.type == "btc_mined": - self._state = str(stats.btc_mined * 0.00000001) + self._attr_state = str(stats.btc_mined * 0.00000001) elif self.type == "trade_volume_usd": - self._state = f"{stats.trade_volume_usd:.1f}" + self._attr_state = f"{stats.trade_volume_usd:.1f}" elif self.type == "difficulty": - self._state = f"{stats.difficulty:.0f}" + self._attr_state = f"{stats.difficulty:.0f}" elif self.type == "minutes_between_blocks": - self._state = f"{stats.minutes_between_blocks:.2f}" + self._attr_state = f"{stats.minutes_between_blocks:.2f}" elif self.type == "number_of_transactions": - self._state = str(stats.number_of_transactions) + self._attr_state = str(stats.number_of_transactions) elif self.type == "hash_rate": - self._state = f"{stats.hash_rate * 0.000001:.1f}" + self._attr_state = f"{stats.hash_rate * 0.000001:.1f}" elif self.type == "timestamp": - self._state = stats.timestamp + self._attr_state = stats.timestamp elif self.type == "mined_blocks": - self._state = str(stats.mined_blocks) + self._attr_state = str(stats.mined_blocks) elif self.type == "blocks_size": - self._state = f"{stats.blocks_size:.1f}" + self._attr_state = f"{stats.blocks_size:.1f}" elif self.type == "total_fees_btc": - self._state = f"{stats.total_fees_btc * 0.00000001:.2f}" + self._attr_state = f"{stats.total_fees_btc * 0.00000001:.2f}" elif self.type == "total_btc_sent": - self._state = f"{stats.total_btc_sent * 0.00000001:.2f}" + self._attr_state = f"{stats.total_btc_sent * 0.00000001:.2f}" elif self.type == "estimated_btc_sent": - self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" + self._attr_state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" elif self.type == "total_btc": - self._state = f"{stats.total_btc * 0.00000001:.2f}" + self._attr_state = f"{stats.total_btc * 0.00000001:.2f}" elif self.type == "total_blocks": - self._state = f"{stats.total_blocks:.0f}" + self._attr_state = f"{stats.total_blocks:.0f}" elif self.type == "next_retarget": - self._state = f"{stats.next_retarget:.2f}" + self._attr_state = f"{stats.next_retarget:.2f}" elif self.type == "estimated_transaction_volume_usd": - self._state = f"{stats.estimated_transaction_volume_usd:.2f}" + self._attr_state = f"{stats.estimated_transaction_volume_usd:.2f}" elif self.type == "miners_revenue_btc": - self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" + self._attr_state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" elif self.type == "market_price_usd": - self._state = f"{stats.market_price_usd:.2f}" + self._attr_state = f"{stats.market_price_usd:.2f}" class BitcoinData: diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index d0cade31a72..16f247693af 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -31,40 +31,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): route = config[CONF_ROUTE] data = Bizkaibus(stop, route) - add_entities([BizkaibusSensor(data, stop, route, name)], True) + add_entities([BizkaibusSensor(data, name)], True) class BizkaibusSensor(SensorEntity): """The class for handling the data.""" - def __init__(self, data, stop, route, name): + _attr_unit_of_measurement = TIME_MINUTES + + def __init__(self, data, name): """Initialize the sensor.""" self.data = data - self.stop = stop - self.route = route - self._name = name - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return TIME_MINUTES + self._attr_name = name def update(self): """Get the latest data from the webservice.""" self.data.update() with suppress(TypeError): - self._state = self.data.info[0][ATTR_DUE_IN] + self._attr_state = self.data.info[0][ATTR_DUE_IN] class Bizkaibus: diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 9ae696a5276..5407580612e 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -131,6 +131,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlackbirdZone(MediaPlayerEntity): """Representation of a Blackbird matrix zone.""" + _attr_supported_features = SUPPORT_BLACKBIRD + def __init__(self, blackbird, sources, zone_id, zone_name): """Initialize new zone.""" self._blackbird = blackbird @@ -139,55 +141,28 @@ class BlackbirdZone(MediaPlayerEntity): # dict source name -> source_id self._source_name_id = {v: k for k, v in sources.items()} # ordered list of all source names - self._source_names = sorted( + self._attr_source_list = sorted( self._source_name_id.keys(), key=lambda v: self._source_name_id[v] ) self._zone_id = zone_id - self._name = zone_name - self._state = None - self._source = None + self._attr_name = zone_name def update(self): """Retrieve latest state.""" state = self._blackbird.zone_status(self._zone_id) if not state: return - self._state = STATE_ON if state.power else STATE_OFF + self._attr_state = STATE_ON if state.power else STATE_OFF idx = state.av if idx in self._source_id_name: - self._source = self._source_id_name[idx] + self._attr_source = self._source_id_name[idx] else: - self._source = None - - @property - def name(self): - """Return the name of the zone.""" - return self._name - - @property - def state(self): - """Return the state of the zone.""" - return self._state - - @property - def supported_features(self): - """Return flag of media commands that are supported.""" - return SUPPORT_BLACKBIRD + self._attr_source = None @property def media_title(self): """Return the current source as media title.""" - return self._source - - @property - def source(self): - """Return the current input source of the device.""" - return self._source - - @property - def source_list(self): - """List of available input sources.""" - return self._source_names + return self.source def set_all_zones(self, source): """Set all zones to one source.""" diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 33d09f460db..95b36612add 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -79,16 +79,16 @@ class BleBoxEntity(Entity): def __init__(self, feature): """Initialize a BleBox entity.""" self._feature = feature - - @property - def name(self): - """Return the internal entity name.""" - return self._feature.full_name - - @property - def unique_id(self): - """Return a unique id.""" - return self._feature.unique_id + self._attr_name = feature.full_name + self._attr_unique_id = feature.unique_id + product = feature.product + self._attr_device_info = { + "identifiers": {(DOMAIN, product.unique_id)}, + "name": product.name, + "manufacturer": product.brand, + "model": product.model, + "sw_version": product.firmware_version, + } async def async_update(self): """Update the entity state.""" @@ -96,15 +96,3 @@ class BleBoxEntity(Entity): await self._feature.async_update() except Error as ex: _LOGGER.error("Updating '%s' failed: %s", self.name, ex) - - @property - def device_info(self): - """Return device information for this entity.""" - product = self._feature.product - return { - "identifiers": {(DOMAIN, product.unique_id)}, - "name": product.name, - "manufacturer": product.brand, - "model": product.model, - "sw_version": product.firmware_version, - } diff --git a/homeassistant/components/blebox/air_quality.py b/homeassistant/components/blebox/air_quality.py index e7e9bac1f97..debf0201a3f 100644 --- a/homeassistant/components/blebox/air_quality.py +++ b/homeassistant/components/blebox/air_quality.py @@ -15,10 +15,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxAirQualityEntity(BleBoxEntity, AirQualityEntity): """Representation of a BleBox air quality feature.""" - @property - def icon(self): - """Return the icon.""" - return "mdi:blur" + _attr_icon = "mdi:blur" @property def particulate_matter_0_1(self): diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 4ee8cf9be76..59e64b772ef 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -25,10 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxClimateEntity(BleBoxEntity, ClimateEntity): """Representation of a BleBox climate feature (saunaBox).""" - @property - def supported_features(self): - """Return the supported climate features.""" - return SUPPORT_TARGET_TEMPERATURE + _attr_supported_features = SUPPORT_TARGET_TEMPERATURE + _attr_hvac_modes = [HVAC_MODE_OFF, HVAC_MODE_HEAT] + _attr_temperature_unit = TEMP_CELSIUS @property def hvac_mode(self): @@ -48,16 +47,6 @@ class BleBoxClimateEntity(BleBoxEntity, ClimateEntity): # NOTE: In practice, there's no need to handle case when is_heating is None return CURRENT_HVAC_HEAT if self._feature.is_heating else CURRENT_HVAC_IDLE - @property - def hvac_modes(self): - """Return a list of possible HVAC modes.""" - return [HVAC_MODE_OFF, HVAC_MODE_HEAT] - - @property - def temperature_unit(self): - """Return the temperature unit.""" - return TEMP_CELSIUS - @property def max_temp(self): """Return the maximum temperature supported.""" diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 620adacf3f6..5dc6a486ed3 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -27,24 +27,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxCoverEntity(BleBoxEntity, CoverEntity): """Representation of a BleBox cover feature.""" + def __init__(self, feature): + """Initialize a BleBox cover feature.""" + super().__init__(feature) + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] + position = SUPPORT_SET_POSITION if feature.is_slider else 0 + stop = SUPPORT_STOP if feature.has_stop else 0 + self._attr_supported_features = position | stop | SUPPORT_OPEN | SUPPORT_CLOSE + @property def state(self): """Return the equivalent HA cover state.""" return BLEBOX_TO_HASS_COVER_STATES[self._feature.state] - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] - - @property - def supported_features(self): - """Return the supported cover features.""" - position = SUPPORT_SET_POSITION if self._feature.is_slider else 0 - stop = SUPPORT_STOP if self._feature.has_stop else 0 - - return position | stop | SUPPORT_OPEN | SUPPORT_CLOSE - @property def current_cover_position(self): """Return the current cover position.""" diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 9bb7371a97a..b03cc16112c 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -29,13 +29,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxLightEntity(BleBoxEntity, LightEntity): """Representation of BleBox lights.""" - @property - def supported_color_modes(self): - """Return supported color modes.""" - return {self.color_mode} + def __init__(self, feature): + """Initialize a BleBox light.""" + super().__init__(feature) + self._attr_supported_color_modes = {self.color_mode} @property - def is_on(self): + def is_on(self) -> bool: """Return if light is on.""" return self._feature.is_on diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index c1b9d8501c1..09bfca88776 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -17,17 +17,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxSensorEntity(BleBoxEntity, SensorEntity): """Representation of a BleBox sensor feature.""" + def __init__(self, feature): + """Initialize a BleBox sensor feature.""" + super().__init__(feature) + self._attr_unit_of_measurement = BLEBOX_TO_UNIT_MAP[feature.unit] + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] + @property def state(self): """Return the state.""" return self._feature.current - - @property - def unit_of_measurement(self): - """Return the unit.""" - return BLEBOX_TO_UNIT_MAP[self._feature.unit] - - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index e88773db639..3769235e943 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -15,10 +15,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BleBoxSwitchEntity(BleBoxEntity, SwitchEntity): """Representation of a BleBox switch feature.""" - @property - def device_class(self): - """Return the device class.""" - return BLEBOX_TO_HASS_DEVICE_CLASSES[self._feature.device_class] + def __init__(self, feature): + """Initialize a BleBox switch feature.""" + super().__init__(feature) + self._attr_device_class = BLEBOX_TO_HASS_DEVICE_CLASSES[feature.device_class] @property def is_on(self): diff --git a/homeassistant/components/blebox/translations/de.json b/homeassistant/components/blebox/translations/de.json index 508e4b66ee6..c104a96fe46 100644 --- a/homeassistant/components/blebox/translations/de.json +++ b/homeassistant/components/blebox/translations/de.json @@ -7,17 +7,17 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler", - "unsupported_version": "Das BleBox-Ger\u00e4t hat eine veraltete Firmware. Bitte aktualisieren Sie es zuerst." + "unsupported_version": "Das BleBox-Ger\u00e4t hat eine veraltete Firmware. Bitte aktualisiere es zuerst." }, "flow_title": "{name} ( {host} )", "step": { "user": { "data": { - "host": "IP Adresse", + "host": "IP-Adresse", "port": "Port" }, - "description": "Richten Sie Ihre BleBox f\u00fcr die Integration mit dem Home Assistant ein.", - "title": "Richten Sie Ihr BleBox-Ger\u00e4t ein" + "description": "Richte deine BleBox f\u00fcr die Integration mit dem Home Assistant ein.", + "title": "Richte dein BleBox-Ger\u00e4t ein" } } } diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index ed2b46acaa1..ea215ebb689 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -29,56 +29,28 @@ async def async_setup_entry(hass, config, async_add_entities): class BlinkSyncModule(AlarmControlPanelEntity): """Representation of a Blink Alarm Control Panel.""" + _attr_icon = ICON + _attr_supported_features = SUPPORT_ALARM_ARM_AWAY + def __init__(self, data, name, sync): """Initialize the alarm control panel.""" self.data = data self.sync = sync self._name = name - self._state = None - - @property - def unique_id(self): - """Return the unique id for the sync module.""" - return self.sync.serial - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_AWAY - - @property - def name(self): - """Return the name of the panel.""" - return f"{DOMAIN} {self._name}" - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attr = self.sync.attributes - attr["network_info"] = self.data.networks - attr["associated_cameras"] = list(self.sync.cameras) - attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION - return attr + self._attr_unique_id = sync.serial + self._attr_name = f"{DOMAIN} {name}" def update(self): """Update the state of the device.""" _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) self.data.refresh() - mode = self.sync.arm - if mode: - self._state = STATE_ALARM_ARMED_AWAY - else: - self._state = STATE_ALARM_DISARMED + self._attr_state = ( + STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + ) + self.sync.attributes["network_info"] = self.data.networks + self.sync.attributes["associated_cameras"] = list(self.sync.cameras) + self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + self._attr_extra_state_attributes = self.sync.attributes def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index f69c94f0f5e..f9b8ec31605 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -33,31 +33,10 @@ class BlinkBinarySensor(BinarySensorEntity): self.data = data self._type = sensor_type name, device_class = BINARY_SENSORS[sensor_type] - self._name = f"{DOMAIN} {camera} {name}" - self._device_class = device_class + self._attr_name = f"{DOMAIN} {camera} {name}" + self._attr_device_class = device_class self._camera = data.cameras[camera] - self._state = None - self._unique_id = f"{self._camera.serial}-{self._type}" - - @property - def name(self): - """Return the name of the blink sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return self._unique_id - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state + self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" def update(self): """Update sensor state.""" @@ -65,4 +44,4 @@ class BlinkBinarySensor(BinarySensorEntity): state = self._camera.attributes[self._type] if self._type == TYPE_BATTERY: state = state != "ok" - self._state = state + self._attr_is_on = state diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 5085686494e..e2216dc8785 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -32,23 +32,10 @@ class BlinkCamera(Camera): """Initialize a camera.""" super().__init__() self.data = data - self._name = f"{DOMAIN} {name}" + self._attr_name = f"{DOMAIN} {name}" self._camera = camera - self._unique_id = f"{camera.serial}-camera" - self.response = None - self.current_image = None - self.last_image = None - _LOGGER.debug("Initialized blink camera %s", self._name) - - @property - def name(self): - """Return the camera name.""" - return self._name - - @property - def unique_id(self): - """Return the unique camera id.""" - return self._unique_id + self._attr_unique_id = f"{camera.serial}-camera" + _LOGGER.debug("Initialized blink camera %s", self.name) @property def extra_state_attributes(self): diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 1ec61900091..1f7cad3f872 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -40,51 +40,23 @@ class BlinkSensor(SensorEntity): def __init__(self, data, camera, sensor_type): """Initialize sensors from Blink camera.""" name, units, device_class = SENSORS[sensor_type] - self._name = f"{DOMAIN} {camera} {name}" - self._camera_name = name - self._type = sensor_type - self._device_class = device_class + self._attr_name = f"{DOMAIN} {camera} {name}" + self._attr_device_class = device_class self.data = data self._camera = data.cameras[camera] - self._state = None - self._unit_of_measurement = units - self._unique_id = f"{self._camera.serial}-{self._type}" - self._sensor_key = self._type - if self._type == "temperature": - self._sensor_key = "temperature_calibrated" - - @property - def name(self): - """Return the name of the camera.""" - return self._name - - @property - def unique_id(self): - """Return the unique id for the camera sensor.""" - return self._unique_id - - @property - def state(self): - """Return the camera's current state.""" - return self._state - - @property - def device_class(self): - """Return the device's class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + self._attr_unit_of_measurement = units + self._attr_unique_id = f"{self._camera.serial}-{sensor_type}" + self._sensor_key = ( + "temperature_calibrated" if sensor_type == "temperature" else sensor_type + ) def update(self): """Retrieve sensor data from the camera.""" self.data.refresh() try: - self._state = self._camera.attributes[self._sensor_key] + self._attr_state = self._camera.attributes[self._sensor_key] except KeyError: - self._state = None + self._attr_state = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index d1120420756..a45eadc3d3a 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -42,57 +42,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlinkStickLight(LightEntity): """Representation of a BlinkStick light.""" + _attr_supported_features = SUPPORT_BLINKSTICK + def __init__(self, stick, name): """Initialize the light.""" self._stick = stick - self._name = name - self._serial = stick.get_serial() - self._hs_color = None - self._brightness = None - - @property - def name(self): - """Return the name of the light.""" - return self._name - - @property - def brightness(self): - """Read back the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Read back the color of the light.""" - return self._hs_color - - @property - def is_on(self): - """Return True if entity is on.""" - return self._brightness > 0 - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_BLINKSTICK + self._attr_name = name def update(self): """Read back the device state.""" rgb_color = self._stick.get_color() hsv = color_util.color_RGB_to_hsv(*rgb_color) - self._hs_color = hsv[:2] - self._brightness = hsv[2] + self._attr_hs_color = hsv[:2] + self._attr_brightness = hsv[2] + self._attr_is_on = self.brightness > 0 def turn_on(self, **kwargs): """Turn the device on.""" if ATTR_HS_COLOR in kwargs: - self._hs_color = kwargs[ATTR_HS_COLOR] + self._attr_hs_color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] else: - self._brightness = 255 + self._attr_brightness = 255 + self._attr_is_on = self.brightness > 0 rgb_color = color_util.color_hsv_to_RGB( - self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100 + self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100 ) self._stick.set_color(red=rgb_color[0], green=rgb_color[1], blue=rgb_color[2]) diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json index 2520d2b1fcc..05f8fe65fb3 100644 --- a/homeassistant/components/blinksticklight/manifest.json +++ b/homeassistant/components/blinksticklight/manifest.json @@ -2,7 +2,7 @@ "domain": "blinksticklight", "name": "BlinkStick", "documentation": "https://www.home-assistant.io/integrations/blinksticklight", - "requirements": ["blinkstick==1.1.8"], + "requirements": ["blinkstick==1.2.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/blinkt/light.py b/homeassistant/components/blinkt/light.py index bb9bbf315e4..e6a3ecd362d 100644 --- a/homeassistant/components/blinkt/light.py +++ b/homeassistant/components/blinkt/light.py @@ -41,77 +41,43 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlinktLight(LightEntity): """Representation of a Blinkt! Light.""" + _attr_supported_features = SUPPORT_BLINKT + _attr_should_poll = False + _attr_assumed_state = True + def __init__(self, blinkt, name, index): """Initialize a Blinkt Light. Default brightness and white color. """ self._blinkt = blinkt - self._name = f"{name}_{index}" + self._attr_name = f"{name}_{index}" self._index = index - self._is_on = False - self._brightness = 255 - self._hs_color = [0, 0] - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def brightness(self): - """Read back the brightness of the light. - - Returns integer in the range of 1-255. - """ - return self._brightness - - @property - def hs_color(self): - """Read back the color of the light.""" - return self._hs_color - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_BLINKT - - @property - def is_on(self): - """Return true if light is on.""" - return self._is_on - - @property - def should_poll(self): - """Return if we should poll this device.""" - return False - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True + self._attr_is_on = False + self._attr_brightness = 255 + self._attr_hs_color = [0, 0] def turn_on(self, **kwargs): """Instruct the light to turn on and set correct brightness & color.""" if ATTR_HS_COLOR in kwargs: - self._hs_color = kwargs[ATTR_HS_COLOR] + self._attr_hs_color = kwargs[ATTR_HS_COLOR] if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] + self._attr_brightness = kwargs[ATTR_BRIGHTNESS] - percent_bright = self._brightness / 255 - rgb_color = color_util.color_hs_to_RGB(*self._hs_color) + percent_bright = self.brightness / 255 + rgb_color = color_util.color_hs_to_RGB(*self.hs_color) self._blinkt.set_pixel( self._index, rgb_color[0], rgb_color[1], rgb_color[2], percent_bright ) self._blinkt.show() - self._is_on = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs): """Instruct the light to turn off.""" self._blinkt.set_pixel(self._index, 0, 0, 0, 0) self._blinkt.show() - self._is_on = False + self._attr_is_on = False self.schedule_update_ha_state() diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index 3ecf4bee319..bbb9c892871 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -46,39 +46,15 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class BlockchainSensor(SensorEntity): """Representation of a Blockchain.com sensor.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + _attr_icon = ICON + _attr_unit_of_measurement = "BTC" + def __init__(self, name, addresses): """Initialize the sensor.""" - self._name = name + self._attr_name = name self.addresses = addresses - self._state = None - self._unit_of_measurement = "BTC" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement this sensor expresses itself in.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} def update(self): """Get the latest state of the sensor.""" - - self._state = get_balance(self.addresses) + self._attr_state = get_balance(self.addresses) diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index 4234b4fb145..858c39c4db9 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -44,32 +44,14 @@ class BloomSkySensor(BinarySensorEntity): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = f"{device['DeviceName']} {sensor_name}" - self._state = None - self._unique_id = f"{self._device_id}-{self._sensor_name}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the BloomSky device and this sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor, from DEVICE_CLASSES.""" - return SENSOR_TYPES.get(self._sensor_name) - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return self._state + self._attr_name = f"{device['DeviceName']} {sensor_name}" + self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_device_class = SENSOR_TYPES.get(sensor_name) def update(self): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() - self._state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] + self._attr_is_on = self._bloomsky.devices[self._device_id]["Data"][ + self._sensor_name + ] diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py index e14e2f5c68b..570842b9c66 100644 --- a/homeassistant/components/bloomsky/camera.py +++ b/homeassistant/components/bloomsky/camera.py @@ -25,7 +25,7 @@ class BloomSkyCamera(Camera): def __init__(self, bs, device): """Initialize access to the BloomSky camera images.""" super().__init__() - self._name = device["DeviceName"] + self._attr_name = device["DeviceName"] self._id = device["DeviceID"] self._bloomsky = bs self._url = "" @@ -35,6 +35,7 @@ class BloomSkyCamera(Camera): # to download the same image over and over. self._last_image = "" self._logger = logging.getLogger(__name__) + self._attr_unique_id = self._id def camera_image(self): """Update the camera's image if it has changed.""" @@ -51,13 +52,3 @@ class BloomSkyCamera(Camera): return None return self._last_image - - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def name(self): - """Return the name of this BloomSky device.""" - return self._name diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 4dc52e1a85c..7aa2fe9baba 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -5,6 +5,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( AREA_SQUARE_METERS, CONF_MONITORED_CONDITIONS, + DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, PRESSURE_INHG, PRESSURE_MBAR, @@ -31,7 +33,7 @@ SENSOR_UNITS_IMPERIAL = { "Humidity": PERCENTAGE, "Pressure": PRESSURE_INHG, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, } # Metric units @@ -40,7 +42,12 @@ SENSOR_UNITS_METRIC = { "Humidity": PERCENTAGE, "Pressure": PRESSURE_MBAR, "Luminance": f"cd/{AREA_SQUARE_METERS}", - "Voltage": "mV", + "Voltage": ELECTRIC_POTENTIAL_MILLIVOLT, +} + +# Device class +SENSOR_DEVICE_CLASS = { + "Temperature": DEVICE_CLASS_TEMPERATURE, } # Which sensors to format numerically @@ -77,39 +84,21 @@ class BloomSkySensor(SensorEntity): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = f"{device['DeviceName']} {sensor_name}" - self._state = None - self._unique_id = f"{self._device_id}-{self._sensor_name}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def name(self): - """Return the name of the BloomSky device and this sensor.""" - return self._name - - @property - def state(self): - """Return the current state, eg. value, of this sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the sensor units.""" + self._attr_name = f"{device['DeviceName']} {sensor_name}" + self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get(sensor_name, None) if self._bloomsky.is_metric: - return SENSOR_UNITS_METRIC.get(self._sensor_name, None) - return SENSOR_UNITS_IMPERIAL.get(self._sensor_name, None) + self._attr_unit_of_measurement = SENSOR_UNITS_METRIC.get(sensor_name, None) + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_DEVICE_CLASS.get(self._sensor_name) def update(self): """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() - state = self._bloomsky.devices[self._device_id]["Data"][self._sensor_name] - - if self._sensor_name in FORMAT_NUMBERS: - self._state = f"{state:.2f}" - else: - self._state = state + self._attr_state = ( + f"{state:.2f}" if self._sensor_name in FORMAT_NUMBERS else state + ) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index dff45ca68bd..a565a0f560c 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -193,8 +193,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for player in target_players: await getattr(player, method["method"])(**params) - for service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[service]["schema"] + for service, method in SERVICE_TO_METHOD.items(): + schema = method["schema"] hass.services.async_register( DOMAIN, service, async_service_handler, schema=schema ) @@ -203,33 +203,29 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" - def __init__(self, hass, host, port=None, name=None, init_callback=None): + _attr_media_content_type = MEDIA_TYPE_MUSIC + + def __init__(self, hass, host, port=DEFAULT_PORT, name=None, init_callback=None): """Initialize the media player.""" self.host = host self._hass = hass self.port = port self._polling_session = async_get_clientsession(hass) self._polling_task = None # The actual polling task. - self._name = name - self._icon = None + self._attr_name = name self._capture_items = [] self._services_items = [] self._preset_items = [] self._sync_status = {} self._status = None - self._last_status_update = None - self._is_online = False + self._is_online = None self._retry_remove = None - self._muted = False self._master = None - self._is_master = False self._group_name = None - self._group_list = [] self._bluesound_device_name = None - + self._is_master = False + self._group_list = [] self._init_callback = init_callback - if self.port is None: - self.port = DEFAULT_PORT class _TimeoutException(Exception): pass @@ -252,12 +248,12 @@ class BluesoundPlayer(MediaPlayerEntity): return None self._sync_status = resp["SyncStatus"].copy() - if not self._name: - self._name = self._sync_status.get("@name", self.host) + if not self.name: + self._attr_name = self._sync_status.get("@name", self.host) if not self._bluesound_device_name: self._bluesound_device_name = self._sync_status.get("@name", self.host) - if not self._icon: - self._icon = self._sync_status.get("@icon", self.host) + if not self.icon: + self._attr_icon = self._sync_status.get("@icon", self.host) master = self._sync_status.get("master") if master is not None: @@ -291,14 +287,14 @@ class BluesoundPlayer(MediaPlayerEntity): await self.async_update_status() except (asyncio.TimeoutError, ClientError, BluesoundPlayer._TimeoutException): - _LOGGER.info("Node %s is offline, retrying later", self._name) + _LOGGER.info("Node %s is offline, retrying later", self.name) await asyncio.sleep(NODE_OFFLINE_CHECK_TIMEOUT) self.start_polling() except CancelledError: - _LOGGER.debug("Stopping the polling of node %s", self._name) + _LOGGER.debug("Stopping the polling of node %s", self.name) except Exception: - _LOGGER.exception("Unexpected error in %s", self._name) + _LOGGER.exception("Unexpected error in %s", self.name) raise def start_polling(self): @@ -402,7 +398,7 @@ class BluesoundPlayer(MediaPlayerEntity): if response.status == HTTP_OK: result = await response.text() self._is_online = True - self._last_status_update = dt_util.utcnow() + self._attr_media_position_updated_at = dt_util.utcnow() self._status = xmltodict.parse(result)["status"].copy() group_name = self._status.get("groupName") @@ -438,11 +434,58 @@ class BluesoundPlayer(MediaPlayerEntity): except (asyncio.TimeoutError, ClientError): self._is_online = False - self._last_status_update = None + self._attr_media_position_updated_at = None self._status = None self.async_write_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", self._name) + _LOGGER.info("Client connection error, marking %s as offline", self.name) raise + self.update_state_attr() + + def update_state_attr(self): + """Update state attributes.""" + if self._status is None: + self._attr_state = STATE_OFF + self._attr_supported_features = 0 + elif self.is_grouped and not self.is_master: + self._attr_state = STATE_GROUPED + self._attr_supported_features = ( + SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE + ) + else: + status = self._status.get("state") + self._attr_state = STATE_IDLE + if status in ("pause", "stop"): + self._attr_state = STATE_PAUSED + elif status in ("stream", "play"): + self._attr_state = STATE_PLAYING + supported = SUPPORT_CLEAR_PLAYLIST + if self._status.get("indexing", "0") == "0": + supported = ( + supported + | SUPPORT_PAUSE + | SUPPORT_PREVIOUS_TRACK + | SUPPORT_NEXT_TRACK + | SUPPORT_PLAY_MEDIA + | SUPPORT_STOP + | SUPPORT_PLAY + | SUPPORT_SELECT_SOURCE + | SUPPORT_SHUFFLE_SET + ) + if self.volume_level is not None and self.volume_level >= 0: + supported = ( + supported + | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_SET + | SUPPORT_VOLUME_MUTE + ) + if self._status.get("canSeek", "") == "1": + supported = supported | SUPPORT_SEEK + self._attr_supported_features = supported + self._attr_extra_state_attributes = {} + if self._group_list: + self._attr_extra_state_attributes = {ATTR_BLUESOUND_GROUP: self._group_list} + self._attr_extra_state_attributes[ATTR_MASTER] = self._is_master + self._attr_shuffle = self._status.get("shuffle", "0") == "1" async def async_trigger_sync_on_all(self): """Trigger sync status update on all devices.""" @@ -542,27 +585,6 @@ class BluesoundPlayer(MediaPlayerEntity): return self._services_items - @property - def media_content_type(self): - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def state(self): - """Return the state of the device.""" - if self._status is None: - return STATE_OFF - - if self.is_grouped and not self.is_master: - return STATE_GROUPED - - status = self._status.get("state") - if status in ("pause", "stop"): - return STATE_PAUSED - if status in ("stream", "play"): - return STATE_PLAYING - return STATE_IDLE - @property def media_title(self): """Title of current playing media.""" @@ -617,7 +639,7 @@ class BluesoundPlayer(MediaPlayerEntity): return None mediastate = self.state - if self._last_status_update is None or mediastate == STATE_IDLE: + if self.media_position_updated_at is None or mediastate == STATE_IDLE: return None position = self._status.get("secs") @@ -626,7 +648,9 @@ class BluesoundPlayer(MediaPlayerEntity): position = float(position) if mediastate == STATE_PLAYING: - position += (dt_util.utcnow() - self._last_status_update).total_seconds() + position += ( + dt_util.utcnow() - self.media_position_updated_at + ).total_seconds() return position @@ -641,11 +665,6 @@ class BluesoundPlayer(MediaPlayerEntity): return None return float(duration) - @property - def media_position_updated_at(self): - """Last time status was updated.""" - return self._last_status_update - @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -668,21 +687,11 @@ class BluesoundPlayer(MediaPlayerEntity): mute = bool(int(mute)) return mute - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def bluesound_device_name(self): """Return the device name as returned by the device.""" return self._bluesound_device_name - @property - def icon(self): - """Return the icon of the device.""" - return self._icon - @property def source_list(self): """List of available input sources.""" @@ -778,58 +787,15 @@ class BluesoundPlayer(MediaPlayerEntity): return None @property - def supported_features(self): - """Flag of media commands that are supported.""" - if self._status is None: - return 0 - - if self.is_grouped and not self.is_master: - return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE - - supported = SUPPORT_CLEAR_PLAYLIST - - if self._status.get("indexing", "0") == "0": - supported = ( - supported - | SUPPORT_PAUSE - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK - | SUPPORT_PLAY_MEDIA - | SUPPORT_STOP - | SUPPORT_PLAY - | SUPPORT_SELECT_SOURCE - | SUPPORT_SHUFFLE_SET - ) - - current_vol = self.volume_level - if current_vol is not None and current_vol >= 0: - supported = ( - supported - | SUPPORT_VOLUME_STEP - | SUPPORT_VOLUME_SET - | SUPPORT_VOLUME_MUTE - ) - - if self._status.get("canSeek", "") == "1": - supported = supported | SUPPORT_SEEK - - return supported - - @property - def is_master(self): + def is_master(self) -> bool: """Return true if player is a coordinator.""" return self._is_master @property - def is_grouped(self): + def is_grouped(self) -> bool: """Return true if player is a coordinator.""" return self._master is not None or self._is_master - @property - def shuffle(self): - """Return true if shuffle is active.""" - return self._status.get("shuffle", "0") == "1" - async def async_join(self, master): """Join the player to a group.""" master_device = [ @@ -849,17 +815,6 @@ class BluesoundPlayer(MediaPlayerEntity): else: _LOGGER.error("Master not found %s", master_device) - @property - def extra_state_attributes(self): - """List members in group.""" - attributes = {} - if self._group_list: - attributes = {ATTR_BLUESOUND_GROUP: self._group_list} - - attributes[ATTR_MASTER] = self._is_master - - return attributes - def rebuild_bluesound_group(self): """Rebuild the list of entities in speaker group.""" if self._group_name is None: diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py index 87de36fdf02..8de2b2ffe8b 100644 --- a/homeassistant/components/bme280/__init__.py +++ b/homeassistant/components/bme280/__init__.py @@ -1 +1,98 @@ """The bme280 component.""" +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL +from homeassistant.helpers import config_validation as cv, discovery + +from .const import ( + CONF_DELTA_TEMP, + CONF_FILTER_MODE, + CONF_I2C_ADDRESS, + CONF_I2C_BUS, + CONF_OPERATION_MODE, + CONF_OVERSAMPLING_HUM, + CONF_OVERSAMPLING_PRES, + CONF_OVERSAMPLING_TEMP, + CONF_SPI_BUS, + CONF_SPI_DEV, + CONF_T_STANDBY, + DEFAULT_DELTA_TEMP, + DEFAULT_FILTER_MODE, + DEFAULT_I2C_ADDRESS, + DEFAULT_I2C_BUS, + DEFAULT_MONITORED, + DEFAULT_NAME, + DEFAULT_OPERATION_MODE, + DEFAULT_OVERSAMPLING_HUM, + DEFAULT_OVERSAMPLING_PRES, + DEFAULT_OVERSAMPLING_TEMP, + DEFAULT_SCAN_INTERVAL, + DEFAULT_T_STANDBY, + DOMAIN, + SENSOR_TYPES, +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, + [ + vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SPI_BUS): vol.Coerce(int), + vol.Optional(CONF_SPI_DEV): vol.Coerce(int), + vol.Optional( + CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS + ): cv.string, + vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce( + int + ), + vol.Optional( + CONF_DELTA_TEMP, default=DEFAULT_DELTA_TEMP + ): vol.Coerce(float), + vol.Optional( + CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED + ): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional( + CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES + ): vol.Coerce(int), + vol.Optional( + CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM + ): vol.Coerce(int), + vol.Optional( + CONF_OPERATION_MODE, default=DEFAULT_OPERATION_MODE + ): vol.Coerce(int), + vol.Optional( + CONF_T_STANDBY, default=DEFAULT_T_STANDBY + ): vol.Coerce(int), + vol.Optional( + CONF_FILTER_MODE, default=DEFAULT_FILTER_MODE + ): vol.Coerce(int), + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + } + ) + ], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up BME280 component.""" + bme280_config = config[DOMAIN] + for bme280_conf in bme280_config: + discovery_info = {SENSOR_DOMAIN: bme280_conf} + hass.async_create_task( + discovery.async_load_platform( + hass, SENSOR_DOMAIN, DOMAIN, discovery_info, config + ) + ) + return True diff --git a/homeassistant/components/bme280/const.py b/homeassistant/components/bme280/const.py new file mode 100644 index 00000000000..19dee41c855 --- /dev/null +++ b/homeassistant/components/bme280/const.py @@ -0,0 +1,46 @@ +"""Constants for the BME280 component.""" +from datetime import timedelta + +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, +) + +# Common +DOMAIN = "bme280" +CONF_OVERSAMPLING_TEMP = "oversampling_temperature" +CONF_OVERSAMPLING_PRES = "oversampling_pressure" +CONF_OVERSAMPLING_HUM = "oversampling_humidity" +CONF_T_STANDBY = "time_standby" +CONF_FILTER_MODE = "filter_mode" +DEFAULT_NAME = "BME280 Sensor" +DEFAULT_OVERSAMPLING_TEMP = 1 +DEFAULT_OVERSAMPLING_PRES = 1 +DEFAULT_OVERSAMPLING_HUM = 1 +DEFAULT_T_STANDBY = 5 +DEFAULT_FILTER_MODE = 0 +DEFAULT_SCAN_INTERVAL = 300 +SENSOR_TEMP = "temperature" +SENSOR_HUMID = "humidity" +SENSOR_PRESS = "pressure" +SENSOR_TYPES = { + SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], + SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], +} +DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) +# SPI +CONF_SPI_DEV = "spi_dev" +CONF_SPI_BUS = "spi_bus" +# I2C +CONF_I2C_ADDRESS = "i2c_address" +CONF_I2C_BUS = "i2c_bus" +CONF_DELTA_TEMP = "delta_temperature" +CONF_OPERATION_MODE = "operation_mode" +DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) +DEFAULT_I2C_ADDRESS = "0x76" +DEFAULT_I2C_BUS = 1 +DEFAULT_DELTA_TEMP = 0.0 diff --git a/homeassistant/components/bme280/manifest.json b/homeassistant/components/bme280/manifest.json index 515e9e460d3..4c997152b5a 100644 --- a/homeassistant/components/bme280/manifest.json +++ b/homeassistant/components/bme280/manifest.json @@ -2,7 +2,11 @@ "domain": "bme280", "name": "Bosch BME280 Environmental Sensor", "documentation": "https://www.home-assistant.io/integrations/bme280", - "requirements": ["i2csense==0.0.4", "smbus-cffi==0.5.1"], + "requirements": [ + "i2csense==0.0.4", + "smbus-cffi==0.5.1", + "bme280spi==0.2.0" + ], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index 2c3ab0303b0..60ce963bf9e 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -1,179 +1,150 @@ """Support for BME280 temperature, humidity and pressure sensor.""" -from contextlib import suppress -from datetime import timedelta from functools import partial import logging -from i2csense.bme280 import BME280 # pylint: disable=import-error +from bme280spi import BME280 as BME280_spi # pylint: disable=import-error +from i2csense.bme280 import BME280 as BME280_i2c # pylint: disable=import-error import smbus -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, - PERCENTAGE, + CONF_SCAN_INTERVAL, TEMP_FAHRENHEIT, ) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from homeassistant.util.temperature import celsius_to_fahrenheit -_LOGGER = logging.getLogger(__name__) - -CONF_I2C_ADDRESS = "i2c_address" -CONF_I2C_BUS = "i2c_bus" -CONF_OVERSAMPLING_TEMP = "oversampling_temperature" -CONF_OVERSAMPLING_PRES = "oversampling_pressure" -CONF_OVERSAMPLING_HUM = "oversampling_humidity" -CONF_OPERATION_MODE = "operation_mode" -CONF_T_STANDBY = "time_standby" -CONF_FILTER_MODE = "filter_mode" -CONF_DELTA_TEMP = "delta_temperature" - -DEFAULT_NAME = "BME280 Sensor" -DEFAULT_I2C_ADDRESS = "0x76" -DEFAULT_I2C_BUS = 1 -DEFAULT_OVERSAMPLING_TEMP = 1 # Temperature oversampling x 1 -DEFAULT_OVERSAMPLING_PRES = 1 # Pressure oversampling x 1 -DEFAULT_OVERSAMPLING_HUM = 1 # Humidity oversampling x 1 -DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2) -DEFAULT_T_STANDBY = 5 # Tstandby 5ms -DEFAULT_FILTER_MODE = 0 # Filter off -DEFAULT_DELTA_TEMP = 0.0 - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3) - -SENSOR_TEMP = "temperature" -SENSOR_HUMID = "humidity" -SENSOR_PRESS = "pressure" -SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", PERCENTAGE], - SENSOR_PRESS: ["Pressure", "mb"], -} -DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP - ): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES - ): vol.Coerce(int), - vol.Optional( - CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM - ): vol.Coerce(int), - vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_OPERATION_MODE): vol.Coerce( - int - ), - vol.Optional(CONF_T_STANDBY, default=DEFAULT_T_STANDBY): vol.Coerce(int), - vol.Optional(CONF_FILTER_MODE, default=DEFAULT_FILTER_MODE): vol.Coerce(int), - vol.Optional(CONF_DELTA_TEMP, default=DEFAULT_DELTA_TEMP): vol.Coerce(float), - } +from .const import ( + CONF_DELTA_TEMP, + CONF_FILTER_MODE, + CONF_I2C_ADDRESS, + CONF_I2C_BUS, + CONF_OPERATION_MODE, + CONF_OVERSAMPLING_HUM, + CONF_OVERSAMPLING_PRES, + CONF_OVERSAMPLING_TEMP, + CONF_SPI_BUS, + CONF_SPI_DEV, + CONF_T_STANDBY, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + SENSOR_HUMID, + SENSOR_PRESS, + SENSOR_TEMP, + SENSOR_TYPES, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the BME280 sensor.""" - + if discovery_info is None: + return SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit - name = config[CONF_NAME] - i2c_address = config[CONF_I2C_ADDRESS] - - bus = smbus.SMBus(config[CONF_I2C_BUS]) - sensor = await hass.async_add_executor_job( - partial( - BME280, - bus, - i2c_address, - osrs_t=config[CONF_OVERSAMPLING_TEMP], - osrs_p=config[CONF_OVERSAMPLING_PRES], - osrs_h=config[CONF_OVERSAMPLING_HUM], - mode=config[CONF_OPERATION_MODE], - t_sb=config[CONF_T_STANDBY], - filter_mode=config[CONF_FILTER_MODE], - delta_temp=config[CONF_DELTA_TEMP], - logger=_LOGGER, - ) - ) - if not sensor.sample_ok: - _LOGGER.error("BME280 sensor not detected at %s", i2c_address) - return False - - sensor_handler = await hass.async_add_executor_job(BME280Handler, sensor) - - dev = [] - with suppress(KeyError): - for variable in config[CONF_MONITORED_CONDITIONS]: - dev.append( - BME280Sensor(sensor_handler, variable, SENSOR_TYPES[variable][1], name) + sensor_conf = discovery_info[SENSOR_DOMAIN] + name = sensor_conf[CONF_NAME] + scan_interval = max(sensor_conf[CONF_SCAN_INTERVAL], MIN_TIME_BETWEEN_UPDATES) + if CONF_SPI_BUS in sensor_conf and CONF_SPI_DEV in sensor_conf: + spi_dev = sensor_conf[CONF_SPI_DEV] + spi_bus = sensor_conf[CONF_SPI_BUS] + _LOGGER.debug("BME280 sensor initialize at %s.%s", spi_bus, spi_dev) + sensor = await hass.async_add_executor_job( + partial( + BME280_spi, + t_mode=sensor_conf[CONF_OVERSAMPLING_TEMP], + p_mode=sensor_conf[CONF_OVERSAMPLING_PRES], + h_mode=sensor_conf[CONF_OVERSAMPLING_HUM], + standby=sensor_conf[CONF_T_STANDBY], + filter=sensor_conf[CONF_FILTER_MODE], + spi_bus=sensor_conf[CONF_SPI_BUS], + spi_dev=sensor_conf[CONF_SPI_DEV], ) + ) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s.%s", spi_bus, spi_dev) + return + else: + i2c_address = sensor_conf[CONF_I2C_ADDRESS] + bus = smbus.SMBus(sensor_conf[CONF_I2C_BUS]) + sensor = await hass.async_add_executor_job( + partial( + BME280_i2c, + bus, + i2c_address, + osrs_t=sensor_conf[CONF_OVERSAMPLING_TEMP], + osrs_p=sensor_conf[CONF_OVERSAMPLING_PRES], + osrs_h=sensor_conf[CONF_OVERSAMPLING_HUM], + mode=sensor_conf[CONF_OPERATION_MODE], + t_sb=sensor_conf[CONF_T_STANDBY], + filter_mode=sensor_conf[CONF_FILTER_MODE], + delta_temp=sensor_conf[CONF_DELTA_TEMP], + ) + ) + if not sensor.sample_ok: + _LOGGER.error("BME280 sensor not detected at %s", i2c_address) + return - async_add_entities(dev, True) + async def async_update_data(): + await hass.async_add_executor_job(sensor.update) + if not sensor.sample_ok: + raise UpdateFailed(f"Bad update of sensor {name}") + return sensor + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update_data, + update_interval=scan_interval, + ) + await coordinator.async_refresh() + entities = [] + for condition in sensor_conf[CONF_MONITORED_CONDITIONS]: + entities.append( + BME280Sensor( + condition, + SENSOR_TYPES[condition][1], + name, + coordinator, + ) + ) + async_add_entities(entities, True) -class BME280Handler: - """BME280 sensor working in i2C bus.""" - - def __init__(self, sensor): - """Initialize the sensor handler.""" - self.sensor = sensor - self.update(True) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, first_reading=False): - """Read sensor data.""" - self.sensor.update(first_reading) - - -class BME280Sensor(SensorEntity): +class BME280Sensor(CoordinatorEntity, SensorEntity): """Implementation of the BME280 sensor.""" - def __init__(self, bme280_client, sensor_type, temp_unit, name): + def __init__(self, sensor_type, temp_unit, name, coordinator): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] - self.bme280_client = bme280_client + super().__init__(coordinator) + self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self.temp_unit = temp_unit self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property def state(self): """Return the state of the sensor.""" - return self._state + if self.type == SENSOR_TEMP: + temperature = round(self.coordinator.data.temperature, 1) + if self.temp_unit == TEMP_FAHRENHEIT: + temperature = round(celsius_to_fahrenheit(temperature), 1) + state = temperature + elif self.type == SENSOR_HUMID: + state = round(self.coordinator.data.humidity, 1) + elif self.type == SENSOR_PRESS: + state = round(self.coordinator.data.pressure, 1) + return state @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement - - async def async_update(self): - """Get the latest data from the BME280 and update the states.""" - await self.hass.async_add_executor_job(self.bme280_client.update) - if self.bme280_client.sensor.sample_ok: - if self.type == SENSOR_TEMP: - temperature = round(self.bme280_client.sensor.temperature, 2) - if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 2) - self._state = temperature - elif self.type == SENSOR_HUMID: - self._state = round(self.bme280_client.sensor.humidity, 1) - elif self.type == SENSOR_PRESS: - self._state = round(self.bme280_client.sensor.pressure, 1) - else: - _LOGGER.warning("Bad update of sensor.%s", self.name) + def should_poll(self) -> bool: + """Return False if entity should not poll.""" + return False diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index f3d6b9428ea..527a971b237 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -11,6 +11,9 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_FAHRENHEIT, ) @@ -53,11 +56,11 @@ SENSOR_PRESS = "pressure" SENSOR_GAS = "gas" SENSOR_AQ = "airquality" SENSOR_TYPES = { - SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", PERCENTAGE], - SENSOR_PRESS: ["Pressure", "mb"], - SENSOR_GAS: ["Gas Resistance", "Ohms"], - SENSOR_AQ: ["Air Quality", PERCENTAGE], + SENSOR_TEMP: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_HUMID: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], + SENSOR_PRESS: ["Pressure", "mb", DEVICE_CLASS_PRESSURE], + SENSOR_GAS: ["Gas Resistance", "Ohms", None], + SENSOR_AQ: ["Air Quality", PERCENTAGE, None], } DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] OVERSAMPLING_VALUES = {0, 1, 2, 4, 8, 16} @@ -320,44 +323,29 @@ class BME680Sensor(SensorEntity): def __init__(self, bme680_client, sensor_type, temp_unit, name): """Initialize the sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] + self._attr_name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self.bme680_client = bme680_client self.temp_unit = temp_unit self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return self._unit_of_measurement + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] async def async_update(self): """Get the latest data from the BME680 and update the states.""" await self.hass.async_add_executor_job(self.bme680_client.update) if self.type == SENSOR_TEMP: - temperature = round(self.bme680_client.sensor_data.temperature, 1) + self._attr_state = round(self.bme680_client.sensor_data.temperature, 1) if self.temp_unit == TEMP_FAHRENHEIT: - temperature = round(celsius_to_fahrenheit(temperature), 1) - self._state = temperature + self._attr_state = round(celsius_to_fahrenheit(self.state), 1) elif self.type == SENSOR_HUMID: - self._state = round(self.bme680_client.sensor_data.humidity, 1) + self._attr_state = round(self.bme680_client.sensor_data.humidity, 1) elif self.type == SENSOR_PRESS: - self._state = round(self.bme680_client.sensor_data.pressure, 1) + self._attr_state = round(self.bme680_client.sensor_data.pressure, 1) elif self.type == SENSOR_GAS: - self._state = int(round(self.bme680_client.sensor_data.gas_resistance, 0)) + self._attr_state = int( + round(self.bme680_client.sensor_data.gas_resistance, 0) + ) elif self.type == SENSOR_AQ: aq_score = self.bme680_client.sensor_data.air_quality if aq_score is not None: - self._state = round(aq_score, 1) + self._attr_state = round(aq_score, 1) diff --git a/homeassistant/components/bmp280/sensor.py b/homeassistant/components/bmp280/sensor.py index ac607578299..7bf355bb736 100644 --- a/homeassistant/components/bmp280/sensor.py +++ b/homeassistant/components/bmp280/sensor.py @@ -77,36 +77,8 @@ class Bmp280Sensor(SensorEntity): ) -> None: """Initialize the sensor.""" self._bmp280 = bmp280 - self._name = name - self._unit_of_measurement = unit_of_measurement - self._device_class = device_class - self._state = None - self._errored = False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def available(self) -> bool: - """Return if the device is currently available.""" - return not self._errored + self._attr_name = name + self._attr_unit_of_measurement = unit_of_measurement class Bmp280TemperatureSensor(Bmp280Sensor): @@ -122,16 +94,16 @@ class Bmp280TemperatureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._state = round(self._bmp280.temperature, 1) - if self._errored: + self._attr_state = round(self._bmp280.temperature, 1) + if not self.available: _LOGGER.warning("Communication restored with temperature sensor") - self._errored = False + self._attr_available = True except OSError: # this is thrown when a working sensor is unplugged between two updates _LOGGER.warning( "Unable to read temperature data due to a communication problem" ) - self._errored = True + self._attr_available = False class Bmp280PressureSensor(Bmp280Sensor): @@ -147,13 +119,13 @@ class Bmp280PressureSensor(Bmp280Sensor): def update(self): """Fetch new state data for the sensor.""" try: - self._state = round(self._bmp280.pressure) - if self._errored: + self._attr_state = round(self._bmp280.pressure) + if not self.available: _LOGGER.warning("Communication restored with pressure sensor") - self._errored = False + self._attr_available = True except OSError: # this is thrown when a working sensor is unplugged between two updates _LOGGER.warning( "Unable to read pressure data due to a communication problem" ) - self._errored = True + self._attr_available = False diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 599892d6a03..3bd2365f88e 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry, discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_utc_time_change from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -317,6 +317,8 @@ class BMWConnectedDriveAccount: class BMWConnectedDriveBaseEntity(Entity): """Common base for BMW entities.""" + _attr_should_poll = False + def __init__(self, account, vehicle): """Initialize sensor.""" self._account = account @@ -326,15 +328,11 @@ class BMWConnectedDriveBaseEntity(Entity): "vin": self._vehicle.vin, ATTR_ATTRIBUTION: ATTRIBUTION, } - - @property - def device_info(self) -> DeviceInfo: - """Return info for device registry.""" - return { - "identifiers": {(DOMAIN, self._vehicle.vin)}, - "name": f'{self._vehicle.attributes.get("brand")} {self._vehicle.name}', - "model": self._vehicle.name, - "manufacturer": self._vehicle.attributes.get("brand"), + self._attr_device_info = { + "identifiers": {(DOMAIN, vehicle.vin)}, + "name": f'{vehicle.attributes.get("brand")} {vehicle.name}', + "model": vehicle.name, + "manufacturer": vehicle.attributes.get("brand"), } @property @@ -342,14 +340,6 @@ class BMWConnectedDriveBaseEntity(Entity): """Return the state attributes of the sensor.""" return self._attrs - @property - def should_poll(self): - """Do not poll this class. - - Updates are triggered from BMWConnectedDriveAccount. - """ - return False - def update_callback(self): """Schedule a state update.""" self.schedule_update_ha_state(True) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index bebb55bbde0..d7f0d150193 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -76,41 +76,45 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): super().__init__(account, vehicle) self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._sensor_name = sensor_name - self._device_class = device_class - self._icon = icon - self._state = None + self._attr_device_class = device_class + self._attr_icon = icon - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return self._unique_id + def update(self): + """Read new state data from the library.""" + vehicle_state = self._vehicle.state - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name + # device class opening: On means open, Off means closed + if self._attribute == "lids": + _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) + self._attr_state = not vehicle_state.all_lids_closed + if self._attribute == "windows": + self._attr_state = not vehicle_state.all_windows_closed + # device class lock: On means unlocked, Off means locked + if self._attribute == "door_lock_state": + # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED + self._attr_state = vehicle_state.door_lock_state not in [ + LockState.LOCKED, + LockState.SECURED, + ] + # device class light: On means light detected, Off means no light + if self._attribute == "lights_parking": + self._attr_state = vehicle_state.are_parking_lights_on + # device class problem: On means problem detected, Off means no problem + if self._attribute == "condition_based_services": + self._attr_state = not vehicle_state.are_all_cbs_ok + if self._attribute == "check_control_messages": + self._attr_state = vehicle_state.has_check_control_messages + # device class power: On means power detected, Off means no power + if self._attribute == "charging_status": + self._attr_state = vehicle_state.charging_status in [ChargingState.CHARGING] + # device class plug: On means device is plugged in, + # Off means device is unplugged + if self._attribute == "connection_status": + self._attr_state = vehicle_state.connection_status == "CONNECTED" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes of the binary sensor.""" vehicle_state = self._vehicle.state result = self._attrs.copy() @@ -144,40 +148,7 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, BinarySensorEntity): elif self._attribute == "connection_status": result["connection_status"] = vehicle_state.connection_status - return sorted(result.items()) - - def update(self): - """Read new state data from the library.""" - vehicle_state = self._vehicle.state - - # device class opening: On means open, Off means closed - if self._attribute == "lids": - _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed) - self._state = not vehicle_state.all_lids_closed - if self._attribute == "windows": - self._state = not vehicle_state.all_windows_closed - # device class lock: On means unlocked, Off means locked - if self._attribute == "door_lock_state": - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._state = vehicle_state.door_lock_state not in [ - LockState.LOCKED, - LockState.SECURED, - ] - # device class light: On means light detected, Off means no light - if self._attribute == "lights_parking": - self._state = vehicle_state.are_parking_lights_on - # device class problem: On means problem detected, Off means no problem - if self._attribute == "condition_based_services": - self._state = not vehicle_state.are_all_cbs_ok - if self._attribute == "check_control_messages": - self._state = vehicle_state.has_check_control_messages - # device class power: On means power detected, Off means no power - if self._attribute == "charging_status": - self._state = vehicle_state.charging_status in [ChargingState.CHARGING] - # device class plug: On means device is plugged in, - # Off means device is unplugged - if self._attribute == "connection_status": - self._state = vehicle_state.connection_status == "CONNECTED" + self._attr_extra_state_attributes = sorted(result.items()) def _format_cbs_report(self, report): result = {} diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py index 25adf6cb09f..62b2ed9b9d9 100644 --- a/homeassistant/components/bmw_connected_drive/device_tracker.py +++ b/homeassistant/components/bmw_connected_drive/device_tracker.py @@ -29,15 +29,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): """BMW Connected Drive device tracker.""" + _attr_force_update = False + _attr_icon = "mdi:car" + def __init__(self, account, vehicle): """Initialize the Tracker.""" super().__init__(account, vehicle) - self._unique_id = vehicle.vin + self._attr_unique_id = vehicle.vin self._location = ( vehicle.state.gps_position if vehicle.state.gps_position else (None, None) ) - self._name = vehicle.name + self._attr_name = vehicle.name @property def latitude(self): @@ -49,31 +52,11 @@ class BMWDeviceTracker(BMWConnectedDriveBaseEntity, TrackerEntity): """Return longitude value of the device.""" return self._location[1] if self._location else None - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - @property def source_type(self): """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return "mdi:car" - - @property - def force_update(self): - """All updates do not need to be written to the state machine.""" - return False - def update(self): """Update state of the decvice tracker.""" self._location = ( diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 97c9be7216b..3d27cf833b6 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,7 +4,6 @@ import logging from bimmer_connected.state import LockState from homeassistant.components.lock import LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from . import DOMAIN as BMW_DOMAIN, BMWConnectedDriveBaseEntity from .const import CONF_ACCOUNT, DATA_ENTRIES @@ -33,50 +32,17 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): super().__init__(account, vehicle) self._attribute = attribute - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._sensor_name = sensor_name - self._state = None - self.door_lock_state_available = ( - DOOR_LOCK_STATE in self._vehicle.available_attributes - ) - - @property - def unique_id(self): - """Return the unique ID of the lock.""" - return self._unique_id - - @property - def name(self): - """Return the name of the lock.""" - return self._name - - @property - def extra_state_attributes(self): - """Return the state attributes of the lock.""" - vehicle_state = self._vehicle.state - result = self._attrs.copy() - - if self.door_lock_state_available: - result["door_lock_state"] = vehicle_state.door_lock_state.value - result["last_update_reason"] = vehicle_state.last_update_reason - return result - - @property - def is_locked(self): - """Return true if lock is locked.""" - if self.door_lock_state_available: - result = self._state == STATE_LOCKED - else: - result = None - return result + self.door_lock_state_available = DOOR_LOCK_STATE in vehicle.available_attributes def lock(self, **kwargs): """Lock the car.""" _LOGGER.debug("%s: locking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_LOCKED + self._attr_is_locked = True self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_lock() @@ -85,18 +51,23 @@ class BMWLock(BMWConnectedDriveBaseEntity, LockEntity): _LOGGER.debug("%s: unlocking doors", self._vehicle.name) # Optimistic state set here because it takes some time before the # update callback response - self._state = STATE_UNLOCKED + self._attr_is_locked = False self.schedule_update_ha_state() self._vehicle.remote_services.trigger_remote_door_unlock() def update(self): """Update state of the lock.""" _LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute) - vehicle_state = self._vehicle.state + if self._vehicle.state.door_lock_state in [LockState.LOCKED, LockState.SECURED]: + self._attr_is_locked = True + else: + self._attr_is_locked = False + if not self.door_lock_state_available: + self._attr_is_locked = None - # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED - self._state = ( - STATE_LOCKED - if vehicle_state.door_lock_state in [LockState.LOCKED, LockState.SECURED] - else STATE_UNLOCKED - ) + vehicle_state = self._vehicle.state + result = self._attrs.copy() + if self.door_lock_state_available: + result["door_lock_state"] = vehicle_state.door_lock_state.value + result["last_update_reason"] = vehicle_state.last_update_reason + self._attr_extra_state_attributes = result diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index aff9e4fd647..17aaa166942 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.15"], + "requirements": ["bimmer_connected==0.7.16"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 48d28e26f8a..df899496339 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -1,7 +1,7 @@ """Support for reading vehicle status from BMW connected drive portal.""" import logging -from bimmer_connected.const import SERVICE_LAST_TRIP, SERVICE_STATUS +from bimmer_connected.const import SERVICE_ALL_TRIPS, SERVICE_LAST_TRIP, SERVICE_STATUS from bimmer_connected.state import ChargingState from homeassistant.components.sensor import SensorEntity @@ -9,8 +9,10 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, DEVICE_CLASS_TIMESTAMP, ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, LENGTH_KILOMETERS, LENGTH_MILES, + MASS_KILOGRAMS, PERCENTAGE, TIME_HOURS, TIME_MINUTES, @@ -60,6 +62,146 @@ ATTR_TO_HA_METRIC = { "electric_distance": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], "saved_fuel": ["mdi:fuel", None, VOLUME_LITERS, False], "total_distance": ["mdi:map-marker-distance", None, LENGTH_KILOMETERS, True], + # AllTrips attributes + "average_combined_consumption_community_average": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_combined_consumption_community_high": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_combined_consumption_community_low": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_combined_consumption_user_average": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + True, + ], + "average_electric_consumption_community_average": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_electric_consumption_community_high": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_electric_consumption_community_low": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_electric_consumption_user_average": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + True, + ], + "average_recuperation_community_average": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_recuperation_community_high": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_recuperation_community_low": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + False, + ], + "average_recuperation_user_average": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_KILOMETERS}", + True, + ], + "chargecycle_range_community_average": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "chargecycle_range_community_high": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "chargecycle_range_community_low": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "chargecycle_range_user_average": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + True, + ], + "chargecycle_range_user_current_charge_cycle": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + True, + ], + "chargecycle_range_user_high": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + True, + ], + "total_electric_distance_community_average": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "total_electric_distance_community_high": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "total_electric_distance_community_low": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "total_electric_distance_user_average": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "total_electric_distance_user_total": [ + "mdi:map-marker-distance", + None, + LENGTH_KILOMETERS, + False, + ], + "total_saved_fuel": ["mdi:fuel", None, VOLUME_LITERS, False], } ATTR_TO_HA_IMPERIAL = { @@ -92,6 +234,146 @@ ATTR_TO_HA_IMPERIAL = { "electric_distance": ["mdi:map-marker-distance", None, LENGTH_MILES, True], "saved_fuel": ["mdi:fuel", None, VOLUME_GALLONS, False], "total_distance": ["mdi:map-marker-distance", None, LENGTH_MILES, True], + # AllTrips attributes + "average_combined_consumption_community_average": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_combined_consumption_community_high": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_combined_consumption_community_low": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_combined_consumption_user_average": [ + "mdi:flash", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + True, + ], + "average_electric_consumption_community_average": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_electric_consumption_community_high": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_electric_consumption_community_low": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_electric_consumption_user_average": [ + "mdi:power-plug-outline", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + True, + ], + "average_recuperation_community_average": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_recuperation_community_high": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_recuperation_community_low": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + False, + ], + "average_recuperation_user_average": [ + "mdi:recycle-variant", + None, + f"{ENERGY_KILO_WATT_HOUR}/100{LENGTH_MILES}", + True, + ], + "chargecycle_range_community_average": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "chargecycle_range_community_high": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "chargecycle_range_community_low": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "chargecycle_range_user_average": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + True, + ], + "chargecycle_range_user_current_charge_cycle": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + True, + ], + "chargecycle_range_user_high": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + True, + ], + "total_electric_distance_community_average": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "total_electric_distance_community_high": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "total_electric_distance_community_low": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "total_electric_distance_user_average": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "total_electric_distance_user_total": [ + "mdi:map-marker-distance", + None, + LENGTH_MILES, + False, + ], + "total_saved_fuel": ["mdi:fuel", None, VOLUME_GALLONS, False], } ATTR_TO_HA_GENERIC = { @@ -104,6 +386,11 @@ ATTR_TO_HA_GENERIC = { "date_utc": [None, DEVICE_CLASS_TIMESTAMP, None, True], "duration": ["mdi:timer-outline", None, TIME_MINUTES, True], "electric_distance_ratio": ["mdi:percent-outline", None, PERCENTAGE, False], + # AllTrips attributes + "battery_size_max": ["mdi:battery-charging-high", None, ENERGY_WATT_HOUR, False], + "reset_date_utc": [None, DEVICE_CLASS_TIMESTAMP, None, False], + "saved_co2": ["mdi:tree-outline", None, MASS_KILOGRAMS, False], + "saved_co2_green_energy": ["mdi:tree-outline", None, MASS_KILOGRAMS, False], } ATTR_TO_HA_METRIC.update(ATTR_TO_HA_GENERIC) @@ -112,6 +399,7 @@ ATTR_TO_HA_IMPERIAL.update(ATTR_TO_HA_GENERIC) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the BMW ConnectedDrive sensors from config entry.""" + # pylint: disable=too-many-nested-blocks if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: attribute_info = ATTR_TO_HA_IMPERIAL else: @@ -145,6 +433,63 @@ async def async_setup_entry(hass, config_entry, async_add_entities): account, vehicle, attribute_name, attribute_info, service ) entities.append(device) + if service == SERVICE_ALL_TRIPS: + for attribute_name in vehicle.state.all_trips.available_attributes: + if attribute_name == "reset_date": + device = BMWConnectedDriveSensor( + account, + vehicle, + "reset_date_utc", + attribute_info, + service, + ) + entities.append(device) + elif attribute_name in ( + "average_combined_consumption", + "average_electric_consumption", + "average_recuperation", + "chargecycle_range", + "total_electric_distance", + ): + for attr in ( + "community_average", + "community_high", + "community_low", + "user_average", + ): + device = BMWConnectedDriveSensor( + account, + vehicle, + f"{attribute_name}_{attr}", + attribute_info, + service, + ) + entities.append(device) + if attribute_name == "chargecycle_range": + for attr in ("user_current_charge_cycle", "user_high"): + device = BMWConnectedDriveSensor( + account, + vehicle, + f"{attribute_name}_{attr}", + attribute_info, + service, + ) + entities.append(device) + if attribute_name == "total_electric_distance": + for attr in ("user_total",): + device = BMWConnectedDriveSensor( + account, + vehicle, + f"{attribute_name}_{attr}", + attribute_info, + service, + ) + entities.append(device) + else: + device = BMWConnectedDriveSensor( + account, vehicle, attribute_name, attribute_info, service + ) + entities.append(device) async_add_entities(entities, True) @@ -158,91 +503,73 @@ class BMWConnectedDriveSensor(BMWConnectedDriveBaseEntity, SensorEntity): self._attribute = attribute self._service = service - self._state = None - if self._service: - self._name = ( - f"{self._vehicle.name} {self._service.lower()}_{self._attribute}" - ) - self._unique_id = ( - f"{self._vehicle.vin}-{self._service.lower()}-{self._attribute}" - ) + if service: + self._attr_name = f"{vehicle.name} {service.lower()}_{attribute}" + self._attr_unique_id = f"{vehicle.vin}-{service.lower()}-{attribute}" else: - self._name = f"{self._vehicle.name} {self._attribute}" - self._unique_id = f"{self._vehicle.vin}-{self._attribute}" + self._attr_name = f"{vehicle.name} {attribute}" + self._attr_unique_id = f"{vehicle.vin}-{attribute}" self._attribute_info = attribute_info - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - vehicle_state = self._vehicle.state - charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] - - if self._attribute == "charging_level_hv": - return icon_for_battery_level( - battery_level=vehicle_state.charging_level_hv, charging=charging_state - ) - icon = self._attribute_info.get(self._attribute, [None, None, None, None])[0] - return icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - enabled_default = self._attribute_info.get( - self._attribute, [None, None, None, True] + self._attr_entity_registry_enabled_default = attribute_info.get( + attribute, [None, None, None, True] )[3] - return enabled_default - - @property - def state(self): - """Return the state of the sensor. - - The return type of this call depends on the attribute that - is configured. - """ - return self._state - - @property - def device_class(self) -> str: - """Get the device class.""" - clss = self._attribute_info.get(self._attribute, [None, None, None, None])[1] - return clss - - @property - def unit_of_measurement(self) -> str: - """Get the unit of measurement.""" - unit = self._attribute_info.get(self._attribute, [None, None, None, None])[2] - return unit + self._attr_device_class = attribute_info.get( + attribute, [None, None, None, None] + )[1] + self._attr_unit_of_measurement = attribute_info.get( + attribute, [None, None, None, None] + )[2] def update(self) -> None: """Read new state data from the library.""" _LOGGER.debug("Updating %s", self._vehicle.name) vehicle_state = self._vehicle.state - vehicle_last_trip = self._vehicle.state.last_trip if self._attribute == "charging_status": - self._state = getattr(vehicle_state, self._attribute).value + self._attr_state = getattr(vehicle_state, self._attribute).value elif self.unit_of_measurement == VOLUME_GALLONS: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.volume(value, VOLUME_LITERS) - self._state = round(value_converted) + self._attr_state = round(value_converted) elif self.unit_of_measurement == LENGTH_MILES: value = getattr(vehicle_state, self._attribute) value_converted = self.hass.config.units.length(value, LENGTH_KILOMETERS) - self._state = round(value_converted) + self._attr_state = round(value_converted) elif self._service is None: - self._state = getattr(vehicle_state, self._attribute) + self._attr_state = getattr(vehicle_state, self._attribute) elif self._service == SERVICE_LAST_TRIP: + vehicle_last_trip = self._vehicle.state.last_trip if self._attribute == "date_utc": date_str = getattr(vehicle_last_trip, "date") - self._state = dt_util.parse_datetime(date_str).isoformat() + self._attr_state = dt_util.parse_datetime(date_str).isoformat() else: - self._state = getattr(vehicle_last_trip, self._attribute) + self._attr_state = getattr(vehicle_last_trip, self._attribute) + elif self._service == SERVICE_ALL_TRIPS: + vehicle_all_trips = self._vehicle.state.all_trips + for attribute in ( + "average_combined_consumption", + "average_electric_consumption", + "average_recuperation", + "chargecycle_range", + "total_electric_distance", + ): + if self._attribute.startswith(f"{attribute}_"): + attr = getattr(vehicle_all_trips, attribute) + sub_attr = self._attribute.replace(f"{attribute}_", "") + self._attr_state = getattr(attr, sub_attr) + return + if self._attribute == "reset_date_utc": + date_str = getattr(vehicle_all_trips, "reset_date") + self._attr_state = dt_util.parse_datetime(date_str).isoformat() + else: + self._attr_state = getattr(vehicle_all_trips, self._attribute) + + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] + + if self._attribute == "charging_level_hv": + self._attr_icon = icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, charging=charging_state + ) + self._attr_icon = self._attribute_info.get( + self._attribute, [None, None, None, None] + )[0] diff --git a/homeassistant/components/bmw_connected_drive/translations/hu.json b/homeassistant/components/bmw_connected_drive/translations/hu.json index 8724f525626..fa3fcf2df57 100644 --- a/homeassistant/components/bmw_connected_drive/translations/hu.json +++ b/homeassistant/components/bmw_connected_drive/translations/hu.json @@ -16,5 +16,15 @@ } } } + }, + "options": { + "step": { + "account_options": { + "data": { + "read_only": "Csak olvashat\u00f3 (csak \u00e9rz\u00e9kel\u0151k \u00e9s \u00e9rtes\u00edt\u00e9sek, szolg\u00e1ltat\u00e1sok v\u00e9grehajt\u00e1sa, z\u00e1rol\u00e1s n\u00e9lk\u00fcl)", + "use_location": "Haszn\u00e1lja a Home Assistant hely\u00e9t az aut\u00f3 helymeghat\u00e1roz\u00e1si lek\u00e9rdez\u00e9seihez (a 2014.07.07. el\u0151tt gy\u00e1rtott nem i3/i8 j\u00e1rm\u0171vekhez sz\u00fcks\u00e9ges)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index ca8432531e5..3a2777b09e8 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -38,27 +38,19 @@ async def async_setup_entry( class BondCover(BondEntity, CoverEntity): """Representation of a Bond cover.""" + _attr_device_class = DEVICE_CLASS_SHADE + def __init__( self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions ) -> None: """Create HA entity representing Bond cover.""" super().__init__(hub, device, bpup_subs) - self._closed: bool | None = None - def _apply_state(self, state: dict) -> None: cover_open = state.get("open") - self._closed = True if cover_open == 0 else False if cover_open == 1 else None - - @property - def device_class(self) -> str | None: - """Get device class.""" - return DEVICE_CLASS_SHADE - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed or not.""" - return self._closed + self._attr_is_closed = ( + True if cover_open == 0 else False if cover_open == 1 else None + ) async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 59425af54d0..3063a3e4efa 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -25,6 +25,8 @@ _FALLBACK_SCAN_INTERVAL = timedelta(seconds=10) class BondEntity(Entity): """Generic Bond entity encapsulating common features of any Bond controlled device.""" + _attr_should_poll = False + def __init__( self, hub: BondHub, @@ -37,31 +39,17 @@ class BondEntity(Entity): self._device = device self._device_id = device.device_id self._sub_device = sub_device - self._available = True + self._attr_available = True self._bpup_subs = bpup_subs self._update_lock: Lock | None = None self._initialized = False - - @property - def unique_id(self) -> str | None: - """Get unique ID for the entity.""" - hub_id = self._hub.bond_id - device_id = self._device_id - sub_device_id: str = f"_{self._sub_device}" if self._sub_device else "" - return f"{hub_id}_{device_id}{sub_device_id}" - - @property - def name(self) -> str | None: - """Get entity name.""" - if self._sub_device: - sub_device_name = self._sub_device.replace("_", " ").title() - return f"{self._device.name} {sub_device_name}" - return self._device.name - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False + sub_device_id: str = f"_{sub_device}" if sub_device else "" + self._attr_unique_id = f"{hub.bond_id}_{device.device_id}{sub_device_id}" + if sub_device: + sub_device_name = sub_device.replace("_", " ").title() + self._attr_name = f"{device.name} {sub_device_name}" + else: + self._attr_name = device.name @property def device_info(self) -> DeviceInfo: @@ -93,16 +81,6 @@ class BondEntity(Entity): return device_info - @property - def assumed_state(self) -> bool: - """Let HA know this entity relies on an assumed state tracked by Bond.""" - return self._hub.is_bridge and not self._device.trust_state - - @property - def available(self) -> bool: - """Report availability of this entity based on last API call results.""" - return self._available - async def async_update(self) -> None: """Fetch assumed state of the cover from the hub using API.""" await self._async_update_from_api() @@ -113,7 +91,7 @@ class BondEntity(Entity): self.hass.is_stopping or self._bpup_subs.alive and self._initialized - and self._available + and self.available ): return @@ -135,13 +113,14 @@ class BondEntity(Entity): try: state: dict = await self._hub.bond.device_state(self._device_id) except (ClientError, AsyncIOTimeoutError, OSError) as error: - if self._available: + if self.available: _LOGGER.warning( "Entity %s has become unavailable", self.entity_id, exc_info=error ) - self._available = False + self._attr_available = False else: self._async_state_callback(state) + self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state @abstractmethod def _apply_state(self, state: dict) -> None: @@ -151,9 +130,9 @@ class BondEntity(Entity): def _async_state_callback(self, state: dict) -> None: """Process a state change.""" self._initialized = True - if not self._available: + if not self.available: _LOGGER.info("Entity %s has come back", self.entity_id) - self._available = True + self._attr_available = True _LOGGER.debug( "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state ) diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index ca9cbf45a7a..31eceda6c41 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -81,26 +81,7 @@ async def async_setup_entry( class BondBaseLight(BondEntity, LightEntity): """Representation of a Bond light.""" - def __init__( - self, - hub: BondHub, - device: BondDevice, - bpup_subs: BPUPSubscriptions, - sub_device: str | None = None, - ) -> None: - """Create HA entity representing Bond light.""" - super().__init__(hub, device, bpup_subs, sub_device) - self._light: int | None = None - - @property - def is_on(self) -> bool: - """Return if light is currently on.""" - return self._light == 1 - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return 0 + _attr_supported_features = 0 class BondLight(BondBaseLight, BondEntity, LightEntity): @@ -115,26 +96,13 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): ) -> None: """Create HA entity representing Bond light.""" super().__init__(hub, device, bpup_subs, sub_device) - self._brightness: int | None = None + if device.supports_set_brightness(): + self._attr_supported_features = SUPPORT_BRIGHTNESS def _apply_state(self, state: dict) -> None: - self._light = state.get("light") - self._brightness = state.get("brightness") - - @property - def supported_features(self) -> int: - """Flag supported features.""" - if self._device.supports_set_brightness(): - return SUPPORT_BRIGHTNESS - return 0 - - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 1..255.""" - brightness_value = ( - round(self._brightness * 255 / 100) if self._brightness else None - ) - return brightness_value + self._attr_is_on = state.get("light") == 1 + brightness = state.get("brightness") + self._attr_brightness = round(brightness * 255 / 100) if brightness else None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -156,7 +124,7 @@ class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" def _apply_state(self, state: dict) -> None: - self._light = state.get("down_light") and state.get("light") + self._attr_is_on = bool(state.get("down_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -175,7 +143,7 @@ class BondUpLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" def _apply_state(self, state: dict) -> None: - self._light = state.get("up_light") and state.get("light") + self._attr_is_on = bool(state.get("up_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -193,29 +161,14 @@ class BondUpLight(BondBaseLight, BondEntity, LightEntity): class BondFireplace(BondEntity, LightEntity): """Representation of a Bond-controlled fireplace.""" - def __init__( - self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions - ) -> None: - """Create HA entity representing Bond fireplace.""" - super().__init__(hub, device, bpup_subs) - - self._power: bool | None = None - # Bond flame level, 0-100 - self._flame: int | None = None + _attr_supported_features = SUPPORT_BRIGHTNESS def _apply_state(self, state: dict) -> None: - self._power = state.get("power") - self._flame = state.get("flame") - - @property - def supported_features(self) -> int: - """Flag brightness as supported feature to represent flame level.""" - return SUPPORT_BRIGHTNESS - - @property - def is_on(self) -> bool: - """Return True if power is on.""" - return self._power == 1 + power = state.get("power") + flame = state.get("flame") + self._attr_is_on = power == 1 + self._attr_brightness = round(flame * 255 / 100) if flame else None + self._attr_icon = "mdi:fireplace" if power == 1 else "mdi:fireplace-off" async def async_turn_on(self, **kwargs: Any) -> None: """Turn the fireplace on.""" @@ -233,13 +186,3 @@ class BondFireplace(BondEntity, LightEntity): _LOGGER.debug("Fireplace async_turn_off called with: %s", kwargs) await self._hub.bond.action(self._device.device_id, Action.turn_off()) - - @property - def brightness(self) -> int | None: - """Return the flame of this fireplace converted to HA brightness between 0..255.""" - return round(self._flame * 255 / 100) if self._flame else None - - @property - def icon(self) -> str | None: - """Show fireplace icon for the entity.""" - return "mdi:fireplace" if self._power == 1 else "mdi:fireplace-off" diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 0f323b1609b..0bb58946f0f 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import BPUP_SUBS, DOMAIN, HUB from .entity import BondEntity -from .utils import BondDevice, BondHub +from .utils import BondHub async def async_setup_entry( @@ -38,21 +38,8 @@ async def async_setup_entry( class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def __init__( - self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions - ) -> None: - """Create HA entity representing Bond generic device (switch).""" - super().__init__(hub, device, bpup_subs) - - self._power: bool | None = None - def _apply_state(self, state: dict) -> None: - self._power = state.get("power") - - @property - def is_on(self) -> bool: - """Return True if power is on.""" - return self._power == 1 + self._attr_is_on = state.get("power") == 1 async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/bond/translations/de.json b/homeassistant/components/bond/translations/de.json index 934e166e0d5..51f0bd0bee4 100644 --- a/homeassistant/components/bond/translations/de.json +++ b/homeassistant/components/bond/translations/de.json @@ -19,7 +19,7 @@ }, "user": { "data": { - "access_token": "Zugriffstoken", + "access_token": "Zugangstoken", "host": "Host" } } diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index d2c2df838c5..29e45ba2359 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -1,5 +1,6 @@ """Platform for binarysensor integration.""" from boschshcpy import SHCBatteryDevice, SHCSession, SHCShutterContact +from boschshcpy.device import SHCDevice from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, @@ -50,37 +51,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class ShutterContactSensor(SHCEntity, BinarySensorEntity): - """Representation of a SHC shutter contact sensor.""" + """Representation of an SHC shutter contact sensor.""" - @property - def is_on(self): - """Return the state of the sensor.""" - return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC shutter contact sensor..""" + super().__init__(device, parent_id, entry_id) switcher = { "ENTRANCE_DOOR": DEVICE_CLASS_DOOR, "REGULAR_WINDOW": DEVICE_CLASS_WINDOW, "FRENCH_WINDOW": DEVICE_CLASS_DOOR, "GENERIC": DEVICE_CLASS_WINDOW, } - return switcher.get(self._device.device_class, DEVICE_CLASS_WINDOW) + self._attr_device_class = switcher.get( + self._device.device_class, DEVICE_CLASS_WINDOW + ) + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._device.state == SHCShutterContact.ShutterContactService.State.OPEN class BatterySensor(SHCEntity, BinarySensorEntity): - """Representation of a SHC battery reporting sensor.""" + """Representation of an SHC battery reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_battery" + _attr_device_class = DEVICE_CLASS_BATTERY - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Battery" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC battery reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Battery" + self._attr_unique_id = f"{device.serial}_battery" @property def is_on(self): @@ -88,8 +89,3 @@ class BatterySensor(SHCEntity, BinarySensorEntity): return ( self._device.batterylevel != SHCBatteryDevice.BatteryLevelService.State.OK ) - - @property - def device_class(self): - """Return the class of the sensor.""" - return DEVICE_CLASS_BATTERY diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index e795f2bdfec..4415a0ff6ef 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -36,7 +36,7 @@ HOST_SCHEMA = vol.Schema( def write_tls_asset(hass: core.HomeAssistant, filename: str, asset: bytes) -> None: """Write the tls assets to disk.""" makedirs(hass.config.path(DOMAIN), exist_ok=True) - with open(hass.config.path(DOMAIN, filename), "w") as file_handle: + with open(hass.config.path(DOMAIN, filename), "w", encoding="utf8") as file_handle: file_handle.write(asset.decode("utf-8")) diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index d693b0cdfcc..a8966ce2f4a 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -20,11 +20,26 @@ async def async_remove_devices(hass, entity, entry_id): class SHCEntity(Entity): """Representation of a SHC base entity.""" + _attr_should_poll = False + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: """Initialize the generic SHC device.""" self._device = device - self._parent_id = parent_id self._entry_id = entry_id + self._attr_name = device.name + self._attr_unique_id = device.serial + self._attr_device_info = { + "identifiers": {(DOMAIN, device.id)}, + "name": device.name, + "manufacturer": device.manufacturer, + "model": device.device_model, + "via_device": ( + DOMAIN, + device.parent_device_id + if device.parent_device_id is not None + else parent_id, + ), + } async def async_added_to_hass(self): """Subscribe to SHC events.""" @@ -50,43 +65,7 @@ class SHCEntity(Entity): service.unsubscribe_callback(self.entity_id) self._device.unsubscribe_callback(self.entity_id) - @property - def unique_id(self): - """Return the unique ID of the device.""" - return self._device.serial - - @property - def name(self): - """Name of the entity.""" - return self._device.name - - @property - def device_id(self): - """Device id of the entity.""" - return self._device.id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self.device_id)}, - "name": self._device.name, - "manufacturer": self._device.manufacturer, - "model": self._device.device_model, - "via_device": ( - DOMAIN, - self._device.parent_device_id - if self._device.parent_device_id is not None - else self._parent_id, - ), - } - @property def available(self): """Return false if status is unavailable.""" return self._device.status == "AVAILABLE" - - @property - def should_poll(self): - """Report polling mode. SHC Entity is communicating via long polling.""" - return False diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 9f3cf2d5bc3..55aa1eb5772 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/sensor.py @@ -1,5 +1,6 @@ """Platform for sensor integration.""" from boschshcpy import SHCSession +from boschshcpy.device import SHCDevice from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( @@ -143,104 +144,67 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TemperatureSensor(SHCEntity, SensorEntity): - """Representation of a SHC temperature reporting sensor.""" + """Representation of an SHC temperature reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_temperature" + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Temperature" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC temperature reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Temperature" + self._attr_unique_id = f"{device.serial}_temperature" @property def state(self): """Return the state of the sensor.""" return self._device.temperature - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return TEMP_CELSIUS - - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_TEMPERATURE - class HumiditySensor(SHCEntity, SensorEntity): - """Representation of a SHC humidity reporting sensor.""" + """Representation of an SHC humidity reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_humidity" + _attr_device_class = DEVICE_CLASS_HUMIDITY + _attr_unit_of_measurement = PERCENTAGE - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Humidity" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC humidity reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Humidity" + self._attr_unique_id = f"{device.serial}_humidity" @property def state(self): """Return the state of the sensor.""" return self._device.humidity - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return PERCENTAGE - - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_HUMIDITY - class PuritySensor(SHCEntity, SensorEntity): - """Representation of a SHC purity reporting sensor.""" + """Representation of an SHC purity reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_purity" + _attr_icon = "mdi:molecule-co2" + _attr_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Purity" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC purity reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Purity" + self._attr_unique_id = f"{device.serial}_purity" @property def state(self): """Return the state of the sensor.""" return self._device.purity - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return CONCENTRATION_PARTS_PER_MILLION - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:molecule-co2" - class AirQualitySensor(SHCEntity, SensorEntity): - """Representation of a SHC airquality reporting sensor.""" + """Representation of an SHC airquality reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_airquality" - - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Air Quality" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC airquality reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Air Quality" + self._attr_unique_id = f"{device.serial}_airquality" @property def state(self): @@ -256,17 +220,13 @@ class AirQualitySensor(SHCEntity, SensorEntity): class TemperatureRatingSensor(SHCEntity, SensorEntity): - """Representation of a SHC temperature rating sensor.""" + """Representation of an SHC temperature rating sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_temperature_rating" - - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Temperature Rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC temperature rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Temperature Rating" + self._attr_unique_id = f"{device.serial}_temperature_rating" @property def state(self): @@ -275,17 +235,13 @@ class TemperatureRatingSensor(SHCEntity, SensorEntity): class HumidityRatingSensor(SHCEntity, SensorEntity): - """Representation of a SHC humidity rating sensor.""" + """Representation of an SHC humidity rating sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_humidity_rating" - - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Humidity Rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC humidity rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Humidity Rating" + self._attr_unique_id = f"{device.serial}_humidity_rating" @property def state(self): @@ -294,17 +250,13 @@ class HumidityRatingSensor(SHCEntity, SensorEntity): class PurityRatingSensor(SHCEntity, SensorEntity): - """Representation of a SHC purity rating sensor.""" + """Representation of an SHC purity rating sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_purity_rating" - - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Purity Rating" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC purity rating sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Purity Rating" + self._attr_unique_id = f"{device.serial}_purity_rating" @property def state(self): @@ -313,91 +265,58 @@ class PurityRatingSensor(SHCEntity, SensorEntity): class PowerSensor(SHCEntity, SensorEntity): - """Representation of a SHC power reporting sensor.""" + """Representation of an SHC power reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_power" + _attr_device_class = DEVICE_CLASS_POWER + _attr_unit_of_measurement = POWER_WATT - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Power" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC power reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Power" + self._attr_unique_id = f"{device.serial}_power" @property def state(self): """Return the state of the sensor.""" return self._device.powerconsumption - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_POWER - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return POWER_WATT - class EnergySensor(SHCEntity, SensorEntity): - """Representation of a SHC energy reporting sensor.""" + """Representation of an SHC energy reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_energy" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Energy" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC energy reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{self._device.name} Energy" + self._attr_unique_id = f"{self._device.serial}_energy" @property def state(self): """Return the state of the sensor.""" return self._device.energyconsumption / 1000.0 - @property - def device_class(self): - """Return the class of this device.""" - return DEVICE_CLASS_ENERGY - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return ENERGY_KILO_WATT_HOUR - class ValveTappetSensor(SHCEntity, SensorEntity): - """Representation of a SHC valve tappet reporting sensor.""" + """Representation of an SHC valve tappet reporting sensor.""" - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device.serial}_valvetappet" + _attr_icon = "mdi:gauge" + _attr_unit_of_measurement = PERCENTAGE - @property - def name(self): - """Return the name of this sensor.""" - return f"{self._device.name} Valvetappet" + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize an SHC valve tappet reporting sensor.""" + super().__init__(device, parent_id, entry_id) + self._attr_name = f"{device.name} Valvetappet" + self._attr_unique_id = f"{device.serial}_valvetappet" @property def state(self): """Return the state of the sensor.""" return self._device.position - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:gauge" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of the sensor.""" - return PERCENTAGE - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index e7f090a4e1b..15fb061ef2b 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -1,5 +1,4 @@ { - "title": "Bosch SHC", "config": { "step": { "user": { @@ -35,4 +34,4 @@ }, "flow_title": "Bosch SHC: {name}" } -} \ No newline at end of file +} diff --git a/homeassistant/components/bosch_shc/translations/de.json b/homeassistant/components/bosch_shc/translations/de.json index 110e986e106..46b6469afe6 100644 --- a/homeassistant/components/bosch_shc/translations/de.json +++ b/homeassistant/components/bosch_shc/translations/de.json @@ -7,14 +7,14 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung", - "pairing_failed": "Pairing fehlgeschlagen; bitte pr\u00fcfen Sie, ob sich der Bosch Smart Home Controller im Pairing-Modus befindet (LED blinkt) und ob Ihr Passwort korrekt ist.", + "pairing_failed": "Pairing fehlgeschlagen; bitte pr\u00fcfe, ob sich der Bosch Smart Home Controller im Pairing-Modus befindet (LED blinkt) und ob dein Passwort korrekt ist.", "session_error": "Sitzungsfehler: API gab Non-OK-Ergebnis zur\u00fcck.", "unknown": "Unerwarteter Fehler" }, "flow_title": "Bosch SHC: {name}", "step": { "confirm_discovery": { - "description": "Bitte dr\u00fccken Sie die frontseitige Taste des Bosch Smart Home Controllers, bis die LED zu blinken beginnt.\nSind Sie bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?" + "description": "Bitte dr\u00fccke die frontseitige Taste des Bosch Smart Home Controllers, bis die LED zu blinken beginnt.\nBist du bereit, mit der Einrichtung von {model} @ {host} in Home Assistant fortzufahren?" }, "credentials": { "data": { @@ -22,14 +22,14 @@ } }, "reauth_confirm": { - "description": "Die bosch_shc-Integration muss Ihr Konto neu authentifizieren", + "description": "Die bosch_shc-Integration muss dein Konto neu authentifizieren", "title": "Integration erneut authentifizieren" }, "user": { "data": { "host": "Host" }, - "description": "Richten Sie Ihren Bosch Smart Home Controller ein, um die \u00dcberwachung und Steuerung mit Home Assistant zu erm\u00f6glichen.", + "description": "Richte deinen Bosch Smart Home Controller ein, um die \u00dcberwachung und Steuerung mit Home Assistant zu erm\u00f6glichen.", "title": "SHC Authentifizierungsparameter" } } diff --git a/homeassistant/components/bosch_shc/translations/fr.json b/homeassistant/components/bosch_shc/translations/fr.json new file mode 100644 index 00000000000..38a48b269b4 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/fr.json @@ -0,0 +1,38 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "pairing_failed": "L'appairage a \u00e9chou\u00e9\u00a0; veuillez v\u00e9rifier que le Bosch Smart Home Controller est en mode d'appairage (voyant clignotant) et que votre mot de passe est correct.", + "session_error": "Erreur de session\u00a0: l'API renvoie un r\u00e9sultat non-OK.", + "unknown": "Erreur inattendue" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "confirm_discovery": { + "description": "Veuillez appuyer sur le bouton situ\u00e9 \u00e0 l'avant du Bosch Smart Home Controller jusqu'\u00e0 ce que le voyant commence \u00e0 clignoter.\n Pr\u00eat \u00e0 continuer \u00e0 configurer {model} @ {host} avec Home Assistant\u00a0?" + }, + "credentials": { + "data": { + "password": "Mot de passe du contr\u00f4leur Smart Home" + } + }, + "reauth_confirm": { + "description": "L'int\u00e9gration bosch_shc doit r\u00e9-authentifier votre compte", + "title": "R\u00e9authentification de l'int\u00e9gration" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurez votre Bosch Smart Home Controller pour permettre la surveillance et le contr\u00f4le avec Home Assistant.", + "title": "Param\u00e8tres d'authentification SHC" + } + } + }, + "title": "Bosch SHC" +} \ No newline at end of file diff --git a/homeassistant/components/bosch_shc/translations/hu.json b/homeassistant/components/bosch_shc/translations/hu.json index 9cd2a0be6c1..8b4ebc6be32 100644 --- a/homeassistant/components/bosch_shc/translations/hu.json +++ b/homeassistant/components/bosch_shc/translations/hu.json @@ -7,17 +7,30 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "pairing_failed": "A p\u00e1ros\u00edt\u00e1s nem siker\u00fclt; K\u00e9rj\u00fck, ellen\u0151rizze, hogy a Bosch Smart Home Controller p\u00e1ros\u00edt\u00e1si m\u00f3dban van-e (villog a LED), \u00e9s hogy a jelszava helyes-e.", + "session_error": "Munkamenet hiba: Az API nem OK eredm\u00e9nyt ad vissza.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "flow_title": "Bosch SHC: {name}", "step": { + "confirm_discovery": { + "description": "K\u00e9rj\u00fck, addig nyomja a Bosch Smart Home Controller el\u00fcls\u0151 gombj\u00e1t, am\u00edg a LED villogni nem kezd.\n K\u00e9szen \u00e1ll a (z) {model} @ {host} be\u00e1ll\u00edt\u00e1s\u00e1nak folytat\u00e1s\u00e1ra a Home Assistant seg\u00edts\u00e9g\u00e9vel?" + }, + "credentials": { + "data": { + "password": "A Smart Home Controller jelszava" + } + }, "reauth_confirm": { + "description": "A bosch_shc integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a fi\u00f3kj\u00e1t", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "user": { "data": { "host": "Hoszt" - } + }, + "description": "\u00c1ll\u00edtsa be a Bosch intelligens otthoni vez\u00e9rl\u0151t, hogy lehet\u0151v\u00e9 tegye a fel\u00fcgyeletet \u00e9s a vez\u00e9rl\u00e9st a Home Assistant seg\u00edts\u00e9g\u00e9vel.", + "title": "SHC hiteles\u00edt\u00e9si param\u00e9terek" } } }, diff --git a/homeassistant/components/bosch_shc/translations/id.json b/homeassistant/components/bosch_shc/translations/id.json new file mode 100644 index 00000000000..c2167eb0f20 --- /dev/null +++ b/homeassistant/components/bosch_shc/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "Bosch SHC: {name}", + "step": { + "reauth_confirm": { + "title": "Autentikasi Ulang Integrasi" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index eecf8533800..744c856a143 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -1,14 +1,20 @@ """The Bravia TV component.""" +from __future__ import annotations + import asyncio +from collections.abc import Iterable from datetime import timedelta import logging +from typing import Final from bravia_tv import BraviaRC from bravia_tv.braviarc import NoIPControl from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -16,11 +22,11 @@ from .const import CLIENTID_PREFIX, CONF_IGNORED_SOURCES, DOMAIN, NICKNAME _LOGGER = logging.getLogger(__name__) -PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] -SCAN_INTERVAL = timedelta(seconds=10) +PLATFORMS: Final[list[str]] = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] +SCAN_INTERVAL: Final = timedelta(seconds=10) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up a config entry.""" host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] @@ -40,7 +46,7 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +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 @@ -52,7 +58,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -async def update_listener(hass, config_entry): +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -64,28 +70,33 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): several platforms. """ - def __init__(self, hass, host, mac, pin, ignored_sources): + def __init__( + self, + hass: HomeAssistant, + host: str, + mac: str, + pin: str, + ignored_sources: list[str], + ) -> None: """Initialize Bravia TV Client.""" self.braviarc = BraviaRC(host, mac) self.pin = pin self.ignored_sources = ignored_sources - self.muted = False - self.channel_name = None - self.channel_number = None - self.media_title = None - self.source = None - self.source_list = [] - self.original_content_list = [] - self.content_mapping = {} - self.duration = None - self.content_uri = None - self.start_date_time = None - self.program_media_type = None - self.audio_output = None - self.min_volume = None - self.max_volume = None - self.volume_level = None + self.muted: bool = False + self.channel_name: str | None = None + self.media_title: str | None = None + self.source: str | None = None + self.source_list: list[str] = [] + self.original_content_list: list[str] = [] + self.content_mapping: dict[str, str] = {} + self.duration: int | None = None + self.content_uri: str | None = None + self.program_media_type: str | None = None + self.audio_output: str | None = None + self.min_volume: int | None = None + self.max_volume: int | None = None + self.volume_level: float | None = None self.is_on = False # Assume that the TV is in Play mode self.playing = True @@ -101,19 +112,20 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): ), ) - def _send_command(self, command, repeats=1): + def _send_command(self, command: str, repeats: int = 1) -> None: """Send a command to the TV.""" for _ in range(repeats): for cmd in command: self.braviarc.send_command(cmd) - def _get_source(self): + def _get_source(self) -> str | None: """Return the name of the source.""" for key, value in self.content_mapping.items(): if value == self.content_uri: return key + return None - def _refresh_volume(self): + def _refresh_volume(self) -> bool: """Refresh volume information.""" volume_info = self.braviarc.get_volume_info(self.audio_output) if volume_info is not None: @@ -122,11 +134,11 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.audio_output = volume_info.get("target") self.min_volume = volume_info.get("minVolume") self.max_volume = volume_info.get("maxVolume") - self.muted = volume_info.get("mute") + self.muted = volume_info.get("mute", False) return True return False - def _refresh_channels(self): + def _refresh_channels(self) -> bool: """Refresh source and channels list.""" if not self.source_list: self.content_mapping = self.braviarc.load_source_list() @@ -138,17 +150,15 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.source_list.append(key) return True - def _refresh_playing_info(self): + def _refresh_playing_info(self) -> None: """Refresh playing information.""" playing_info = self.braviarc.get_playing_info() program_name = playing_info.get("programTitle") self.channel_name = playing_info.get("title") self.program_media_type = playing_info.get("programMediaType") - self.channel_number = playing_info.get("dispNum") self.content_uri = playing_info.get("uri") self.source = self._get_source() self.duration = playing_info.get("durationSec") - self.start_date_time = playing_info.get("startDateTime") if not playing_info: self.channel_name = "App" if self.channel_name is not None: @@ -158,7 +168,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): else: self.media_title = None - def _update_tv_data(self): + def _update_tv_data(self) -> None: """Connect and update TV info.""" power_status = self.braviarc.get_power_status() @@ -182,26 +192,26 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): self.is_on = False - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch the latest data.""" if self.state_lock.locked(): return await self.hass.async_add_executor_job(self._update_tv_data) - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the device on.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.turn_on) await self.async_request_refresh() - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn off device.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.turn_off) await self.async_request_refresh() - async def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" async with self.state_lock: await self.hass.async_add_executor_job( @@ -209,7 +219,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): ) await self.async_request_refresh() - async def async_volume_up(self): + async def async_volume_up(self) -> None: """Send volume up command to device.""" async with self.state_lock: await self.hass.async_add_executor_job( @@ -217,7 +227,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): ) await self.async_request_refresh() - async def async_volume_down(self): + async def async_volume_down(self) -> None: """Send volume down command to device.""" async with self.state_lock: await self.hass.async_add_executor_job( @@ -225,46 +235,46 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): ) await self.async_request_refresh() - async def async_volume_mute(self, mute): + async def async_volume_mute(self, mute: bool) -> None: """Send mute command to device.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.mute_volume, mute) await self.async_request_refresh() - async def async_media_play(self): + async def async_media_play(self) -> None: """Send play command to device.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.media_play) self.playing = True await self.async_request_refresh() - async def async_media_pause(self): + async def async_media_pause(self) -> None: """Send pause command to device.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.media_pause) self.playing = False await self.async_request_refresh() - async def async_media_stop(self): + async def async_media_stop(self) -> None: """Send stop command to device.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.media_stop) self.playing = False await self.async_request_refresh() - async def async_media_next_track(self): + async def async_media_next_track(self) -> None: """Send next track command.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.media_next_track) await self.async_request_refresh() - async def async_media_previous_track(self): + async def async_media_previous_track(self) -> None: """Send previous track command.""" async with self.state_lock: await self.hass.async_add_executor_job(self.braviarc.media_previous_track) await self.async_request_refresh() - async def async_select_source(self, source): + async def async_select_source(self, source: str) -> None: """Set the input source.""" if source in self.content_mapping: uri = self.content_mapping[source] @@ -272,7 +282,7 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): await self.hass.async_add_executor_job(self.braviarc.play_content, uri) await self.async_request_refresh() - async def async_send_command(self, command, repeats): + async def async_send_command(self, command: Iterable[str], repeats: int) -> None: """Send command to device.""" async with self.state_lock: await self.hass.async_add_executor_job(self._send_command, command, repeats) diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 0813a3e52c5..159f3806d61 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -1,14 +1,20 @@ """Adds config flow for Bravia TV integration.""" +from __future__ import annotations + +from contextlib import suppress import ipaddress import re +from typing import Any from bravia_tv import BraviaRC from bravia_tv.braviarc import NoIPControl import voluptuous as vol from homeassistant import config_entries, exceptions +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from .const import ( @@ -22,14 +28,13 @@ from .const import ( ) -def host_valid(host): +def host_valid(host: str) -> bool: """Return True if hostname or IP address is valid.""" - try: + with suppress(ValueError): if ipaddress.ip_address(host).version in [4, 6]: return True - except ValueError: - disallowed = re.compile(r"[^a-zA-Z\d\-]") - return all(x and not disallowed.search(x) for x in host.split(".")) + disallowed = re.compile(r"[^a-zA-Z\d\-]") + return all(x and not disallowed.search(x) for x in host.split(".")) class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -37,15 +42,16 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.braviarc = None - self.host = None - self.title = None - self.mac = None + self.braviarc: BraviaRC | None = None + self.host: str | None = None + self.title = "" + self.mac: str | None = None - async def init_device(self, pin): + async def init_device(self, pin: str) -> None: """Initialize Bravia TV device.""" + assert self.braviarc is not None await self.hass.async_add_executor_job( self.braviarc.connect, pin, CLIENTID_PREFIX, NICKNAME ) @@ -68,13 +74,15 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> BraviaTVOptionsFlowHandler: """Bravia TV options callback.""" return BraviaTVOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: if host_valid(user_input[CONF_HOST]): @@ -91,9 +99,11 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_authorize(self, user_input=None): + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Get PIN from the Bravia TV device.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: @@ -106,9 +116,9 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_HOST] = self.host user_input[CONF_MAC] = self.mac return self.async_create_entry(title=self.title, data=user_input) - # Connecting with th PIN "0000" to start the pairing process on the TV. try: + assert self.braviarc is not None await self.hass.async_add_executor_job( self.braviarc.connect, "0000", CLIENTID_PREFIX, NICKNAME ) @@ -125,31 +135,34 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Bravia TV.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize Bravia TV options flow.""" - self.braviarc = None self.config_entry = config_entry self.pin = config_entry.data[CONF_PIN] self.ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES) - self.source_list = [] + self.source_list: dict[str, str] = {} - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" coordinator = self.hass.data[DOMAIN][self.config_entry.entry_id] - self.braviarc = coordinator.braviarc - connected = await self.hass.async_add_executor_job(self.braviarc.is_connected) + braviarc = coordinator.braviarc + connected = await self.hass.async_add_executor_job(braviarc.is_connected) if not connected: await self.hass.async_add_executor_job( - self.braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME + braviarc.connect, self.pin, CLIENTID_PREFIX, NICKNAME ) content_mapping = await self.hass.async_add_executor_job( - self.braviarc.load_source_list + braviarc.load_source_list ) - self.source_list = [*content_mapping] + self.source_list = {item: item for item in [*content_mapping]} return await self.async_step_user() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 1fa96e6a98d..01746cbe963 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -1,13 +1,17 @@ """Constants for Bravia TV integration.""" -ATTR_CID = "cid" -ATTR_MAC = "macAddr" -ATTR_MANUFACTURER = "Sony" -ATTR_MODEL = "model" +from __future__ import annotations -CONF_IGNORED_SOURCES = "ignored_sources" +from typing import Final -BRAVIA_CONFIG_FILE = "bravia.conf" -CLIENTID_PREFIX = "HomeAssistant" -DEFAULT_NAME = f"{ATTR_MANUFACTURER} Bravia TV" -DOMAIN = "braviatv" -NICKNAME = "Home Assistant" +ATTR_CID: Final = "cid" +ATTR_MAC: Final = "macAddr" +ATTR_MANUFACTURER: Final = "Sony" +ATTR_MODEL: Final = "model" + +CONF_IGNORED_SOURCES: Final = "ignored_sources" + +BRAVIA_CONFIG_FILE: Final = "bravia.conf" +CLIENTID_PREFIX: Final = "HomeAssistant" +DEFAULT_NAME: Final = f"{ATTR_MANUFACTURER} Bravia TV" +DOMAIN: Final = "braviatv" +NICKNAME: Final = "Home Assistant" diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index dda5b005497..8528e3649c1 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -1,4 +1,8 @@ """Support for interface with a Bravia TV.""" +from __future__ import annotations + +from typing import Final + from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerEntity from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, @@ -13,12 +17,17 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import BraviaTVCoordinator from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN -SUPPORT_BRAVIA = ( +SUPPORT_BRAVIA: Final = ( SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE @@ -33,12 +42,17 @@ SUPPORT_BRAVIA = ( ) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Bravia TV Media Player from a config_entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] unique_id = config_entry.unique_id - device_info = { + assert unique_id is not None + device_info: DeviceInfo = { "identifiers": {(DOMAIN, unique_id)}, "name": DEFAULT_NAME, "manufacturer": ATTR_MANUFACTURER, @@ -53,10 +67,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BraviaTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): """Representation of a Bravia TV Media Player.""" + coordinator: BraviaTVCoordinator _attr_device_class = DEVICE_CLASS_TV _attr_supported_features = SUPPORT_BRAVIA - def __init__(self, coordinator, name, unique_id, device_info): + def __init__( + self, + coordinator: BraviaTVCoordinator, + name: str, + unique_id: str, + device_info: DeviceInfo, + ) -> None: """Initialize the entity.""" self._attr_device_info = device_info @@ -66,91 +87,91 @@ class BraviaTVMediaPlayer(CoordinatorEntity, MediaPlayerEntity): super().__init__(coordinator) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self.coordinator.is_on: return STATE_PLAYING if self.coordinator.playing else STATE_PAUSED return STATE_OFF @property - def source(self): + def source(self) -> str | None: """Return the current input source.""" return self.coordinator.source @property - def source_list(self): + def source_list(self) -> list[str]: """List of available input sources.""" return self.coordinator.source_list @property - def volume_level(self): + def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self.coordinator.volume_level @property - def is_volume_muted(self): + def is_volume_muted(self) -> bool: """Boolean if volume is currently muted.""" return self.coordinator.muted @property - def media_title(self): + def media_title(self) -> str | None: """Title of current playing media.""" return self.coordinator.media_title @property - def media_content_id(self): + def media_content_id(self) -> str | None: """Content ID of current playing media.""" return self.coordinator.channel_name @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" return self.coordinator.duration - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the device on.""" await self.coordinator.async_turn_on() - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn the device off.""" await self.coordinator.async_turn_off() - async def async_set_volume_level(self, volume): + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self.coordinator.async_set_volume_level(volume) - async def async_volume_up(self): + async def async_volume_up(self) -> None: """Send volume up command.""" await self.coordinator.async_volume_up() - async def async_volume_down(self): + async def async_volume_down(self) -> None: """Send volume down command.""" await self.coordinator.async_volume_down() - async def async_mute_volume(self, mute): + async def async_mute_volume(self, mute: bool) -> None: """Send mute command.""" await self.coordinator.async_volume_mute(mute) - async def async_select_source(self, source): + async def async_select_source(self, source: str) -> None: """Set the input source.""" await self.coordinator.async_select_source(source) - async def async_media_play(self): + async def async_media_play(self) -> None: """Send play command.""" await self.coordinator.async_media_play() - async def async_media_pause(self): + async def async_media_pause(self) -> None: """Send pause command.""" await self.coordinator.async_media_pause() - async def async_media_stop(self): + async def async_media_stop(self) -> None: """Send media stop command to media player.""" await self.coordinator.async_media_stop() - async def async_media_next_track(self): + async def async_media_next_track(self) -> None: """Send next track command.""" await self.coordinator.async_media_next_track() - async def async_media_previous_track(self): + async def async_media_previous_track(self) -> None: """Send previous track command.""" await self.coordinator.async_media_previous_track() diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 613d67f0187..81761240320 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -1,17 +1,31 @@ """Remote control support for Bravia TV.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import BraviaTVCoordinator from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Bravia TV Remote from a config entry.""" coordinator = hass.data[DOMAIN][config_entry.entry_id] unique_id = config_entry.unique_id - device_info = { + assert unique_id is not None + device_info: DeviceInfo = { "identifiers": {(DOMAIN, unique_id)}, "name": DEFAULT_NAME, "manufacturer": ATTR_MANUFACTURER, @@ -26,7 +40,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BraviaTVRemote(CoordinatorEntity, RemoteEntity): """Representation of a Bravia TV Remote.""" - def __init__(self, coordinator, name, unique_id, device_info): + coordinator: BraviaTVCoordinator + + def __init__( + self, + coordinator: BraviaTVCoordinator, + name: str, + unique_id: str, + device_info: DeviceInfo, + ) -> None: """Initialize the entity.""" self._attr_device_info = device_info @@ -36,19 +58,19 @@ class BraviaTVRemote(CoordinatorEntity, RemoteEntity): super().__init__(coordinator) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self.coordinator.is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.coordinator.async_turn_on() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.coordinator.async_turn_off() - async def async_send_command(self, command, **kwargs): + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to device.""" repeats = kwargs[ATTR_NUM_REPEATS] await self.coordinator.async_send_command(command, repeats) diff --git a/homeassistant/components/braviatv/translations/de.json b/homeassistant/components/braviatv/translations/de.json index 7dfff8a1b44..cca5c5aa47f 100644 --- a/homeassistant/components/braviatv/translations/de.json +++ b/homeassistant/components/braviatv/translations/de.json @@ -7,21 +7,21 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", - "unsupported_model": "Ihr TV-Modell wird nicht unterst\u00fctzt." + "unsupported_model": "Dein TV-Modell wird nicht unterst\u00fctzt." }, "step": { "authorize": { "data": { "pin": "PIN-Code" }, - "description": "Geben Sie den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, m\u00fcssen Sie die Registrierung von Home Assistant auf Ihrem Fernseher aufheben, gehen Sie daf\u00fcr zu: Einstellungen -> Netzwerk -> Remote - Ger\u00e4teeinstellungen -> Registrierung des entfernten Ger\u00e4ts aufheben.", - "title": "Autorisieren Sie Sony Bravia TV" + "description": "Gib den auf dem Sony Bravia-Fernseher angezeigten PIN-Code ein. \n\nWenn der PIN-Code nicht angezeigt wird, musst du die Registrierung von Home Assistant auf deinem Fernseher aufheben, gehe daf\u00fcr zu: Einstellungen -> Netzwerk -> Remote - Ger\u00e4teeinstellungen -> Registrierung des entfernten Ger\u00e4ts aufheben.", + "title": "Autorisiere Sony Bravia TV" }, "user": { "data": { "host": "Host" }, - "description": "Richten Sie die Sony Bravia TV-Integration ein. Wenn Sie Probleme mit der Konfiguration haben, gehen Sie zu: https://www.home-assistant.io/integrations/braviatv \n\n Stellen Sie sicher, dass Ihr Fernseher eingeschaltet ist.", + "description": "Richte die Sony Bravia TV-Integration ein. Wenn du Probleme mit der Konfiguration hast, gehe zu: https://www.home-assistant.io/integrations/braviatv \n\nStelle sicher, dass dein Fernseher eingeschaltet ist.", "title": "Sony Bravia TV" } } diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index fd060d23b35..ed56e42342d 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -7,7 +7,16 @@ DOMAIN = "broadlink" DOMAINS_AND_TYPES = { REMOTE_DOMAIN: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, - SENSOR_DOMAIN: {"A1", "RM4MINI", "RM4PRO", "RMPRO"}, + SENSOR_DOMAIN: { + "A1", + "RM4MINI", + "RM4PRO", + "RMPRO", + "SP2S", + "SP3S", + "SP4", + "SP4B", + }, SWITCH_DOMAIN: { "BG1", "MP1", diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py index 850611b391f..bd2f938a2bd 100644 --- a/homeassistant/components/broadlink/entity.py +++ b/homeassistant/components/broadlink/entity.py @@ -1,18 +1,49 @@ """Broadlink entities.""" from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import Entity from .const import DOMAIN -class BroadlinkEntity: +class BroadlinkEntity(Entity): """Representation of a Broadlink entity.""" _attr_should_poll = False def __init__(self, device): - """Initialize the device.""" + """Initialize the entity.""" self._device = device + self._coordinator = device.update_manager.coordinator + + async def async_added_to_hass(self): + """Call when the entity is added to hass.""" + self.async_on_remove(self._coordinator.async_add_listener(self._recv_data)) + + async def async_update(self): + """Update the state of the entity.""" + await self._coordinator.async_request_refresh() + + def _recv_data(self): + """Receive data from the update coordinator. + + This event listener should be called by the coordinator whenever + there is an update available. + + It works as a template for the _update_state() method, which should + be overridden by child classes in order to update the state of the + entities, when applicable. + """ + if self._coordinator.last_update_success: + self._update_state(self._coordinator.data) + self.async_write_ha_state() + + def _update_state(self, data): + """Update the state of the entity. + + This method should be overridden by child classes in order to + internalize state and attributes received from the coordinator. + """ @property def available(self): diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index b9dd34d22d8..a0c5c4130e5 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -119,7 +119,6 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): def __init__(self, device, codes, flags): """Initialize the remote.""" super().__init__(device) - self._coordinator = device.update_manager.coordinator self._code_storage = codes self._flag_storage = flags self._storage_loaded = False @@ -127,10 +126,10 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): self._flags = defaultdict(int) self._lock = asyncio.Lock() - self._attr_name = f"{self._device.name} Remote" + self._attr_name = f"{device.name} Remote" self._attr_is_on = True self._attr_supported_features = SUPPORT_LEARN_COMMAND | SUPPORT_DELETE_COMMAND - self._attr_unique_id = self._device.unique_id + self._attr_unique_id = device.unique_id def _extract_codes(self, commands, device=None): """Extract a list of codes. @@ -189,14 +188,7 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): """Call when the remote is added to hass.""" state = await self.async_get_last_state() self._attr_is_on = state is None or state.state != STATE_OFF - - self.async_on_remove( - self._coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self): - """Update the remote.""" - await self._coordinator.async_request_refresh() + await super().async_added_to_hass() async def async_turn_on(self, **kwargs): """Turn on the remote.""" diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 851668fdeff..30bc8047d03 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -6,13 +6,13 @@ import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, PLATFORM_SCHEMA, STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import CONF_HOST, PERCENTAGE, TEMP_CELSIUS -from homeassistant.core import callback +from homeassistant.const import CONF_HOST, PERCENTAGE, POWER_WATT, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv from .const import DOMAIN @@ -37,6 +37,12 @@ SENSOR_TYPES = { ), "light": ("Light", None, DEVICE_CLASS_ILLUMINANCE, None), "noise": ("Noise", None, None, None), + "power": ( + "Current power", + POWER_WATT, + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -63,7 +69,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [ BroadlinkSensor(device, monitored_condition) for monitored_condition in sensor_data - if sensor_data[monitored_condition] != 0 or device.api.type == "A1" + if monitored_condition in SENSOR_TYPES + and ( + # These devices have optional sensors. + # We don't create entities if the value is 0. + sensor_data[monitored_condition] != 0 + or device.api.type not in {"RM4PRO", "RM4MINI"} + ) ] async_add_entities(sensors) @@ -74,29 +86,15 @@ class BroadlinkSensor(BroadlinkEntity, SensorEntity): def __init__(self, device, monitored_condition): """Initialize the sensor.""" super().__init__(device) - self._coordinator = device.update_manager.coordinator self._monitored_condition = monitored_condition - self._attr_device_class = SENSOR_TYPES[self._monitored_condition][2] - self._attr_name = ( - f"{self._device.name} {SENSOR_TYPES[self._monitored_condition][0]}" - ) - self._attr_state_class = SENSOR_TYPES[self._monitored_condition][3] + self._attr_device_class = SENSOR_TYPES[monitored_condition][2] + self._attr_name = f"{device.name} {SENSOR_TYPES[monitored_condition][0]}" + self._attr_state_class = SENSOR_TYPES[monitored_condition][3] self._attr_state = self._coordinator.data[monitored_condition] - self._attr_unique_id = f"{self._device.unique_id}-{self._monitored_condition}" - self._attr_unit_of_measurement = SENSOR_TYPES[self._monitored_condition][1] + self._attr_unique_id = f"{device.unique_id}-{monitored_condition}" + self._attr_unit_of_measurement = SENSOR_TYPES[monitored_condition][1] - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._attr_state = self._coordinator.data[self._monitored_condition] - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Call when the sensor is added to hass.""" - self.async_on_remove(self._coordinator.async_add_listener(self.update_data)) - - async def async_update(self): - """Update the sensor.""" - await self._coordinator.async_request_refresh() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_state = data[self._monitored_condition] diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 1576c8b8418..9fb7215e2a9 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -24,7 +24,6 @@ from homeassistant.const import ( CONF_TYPE, STATE_ON, ) -from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity @@ -135,49 +134,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): """Representation of a Broadlink switch.""" + _attr_assumed_state = True + _attr_device_class = DEVICE_CLASS_SWITCH + def __init__(self, device, command_on, command_off): """Initialize the switch.""" super().__init__(device) self._command_on = command_on self._command_off = command_off - self._coordinator = device.update_manager.coordinator - self._state = None self._attr_assumed_state = True self._attr_device_class = DEVICE_CLASS_SWITCH - self._attr_name = f"{self._device.name} Switch" - - @property - def is_on(self): - """Return True if the switch is on.""" - return self._state - - @callback - def update_data(self): - """Update data.""" - self.async_write_ha_state() + self._attr_name = f"{device.name} Switch" async def async_added_to_hass(self): """Call when the switch is added to hass.""" - if self._state is None: - state = await self.async_get_last_state() - self._state = state is not None and state.state == STATE_ON - self.async_on_remove(self._coordinator.async_add_listener(self.update_data)) - - async def async_update(self): - """Update the switch.""" - await self._coordinator.async_request_refresh() + state = await self.async_get_last_state() + self._attr_is_on = state is not None and state.state == STATE_ON + await super().async_added_to_hass() async def async_turn_on(self, **kwargs): """Turn on the switch.""" if await self._async_send_packet(self._command_on): - self._state = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn off the switch.""" if await self._async_send_packet(self._command_off): - self._state = False + self._attr_is_on = False self.async_write_ha_state() @abstractmethod @@ -229,47 +214,34 @@ class BroadlinkSP1Switch(BroadlinkSwitch): class BroadlinkSP2Switch(BroadlinkSP1Switch): """Representation of a Broadlink SP2 switch.""" + _attr_assumed_state = False + def __init__(self, device, *args, **kwargs): """Initialize the switch.""" super().__init__(device, *args, **kwargs) - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") + self._attr_is_on = self._coordinator.data["pwr"] - self._attr_assumed_state = False - - @property - def current_power_w(self): - """Return the current power usage in Watt.""" - return self._load_power - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data["pwr"] - self._load_power = self._coordinator.data.get("power") - self.async_write_ha_state() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data["pwr"] class BroadlinkMP1Slot(BroadlinkSwitch): """Representation of a Broadlink MP1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"s{slot}"] + self._attr_is_on = self._coordinator.data[f"s{slot}"] + self._attr_name = f"{device.name} S{slot}" + self._attr_unique_id = f"{device.unique_id}-s{slot}" - self._attr_name = f"{self._device.name} S{self._slot}" - self._attr_unique_id = f"{self._device.unique_id}-s{self._slot}" - self._attr_assumed_state = False - - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"s{self._slot}"] - self.async_write_ha_state() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data[f"s{self._slot}"] async def _async_send_packet(self, packet): """Send a packet to the device.""" @@ -286,23 +258,21 @@ class BroadlinkMP1Slot(BroadlinkSwitch): class BroadlinkBG1Slot(BroadlinkSwitch): """Representation of a Broadlink BG1 slot.""" + _attr_assumed_state = False + def __init__(self, device, slot): """Initialize the switch.""" super().__init__(device, 1, 0) self._slot = slot - self._state = self._coordinator.data[f"pwr{slot}"] + self._attr_is_on = self._coordinator.data[f"pwr{slot}"] - self._attr_name = f"{self._device.name} S{self._slot}" + self._attr_name = f"{device.name} S{slot}" self._attr_device_class = DEVICE_CLASS_OUTLET - self._attr_unique_id = f"{self._device.unique_id}-s{self._slot}" - self._attr_assumed_state = False + self._attr_unique_id = f"{device.unique_id}-s{slot}" - @callback - def update_data(self): - """Update data.""" - if self._coordinator.last_update_success: - self._state = self._coordinator.data[f"pwr{self._slot}"] - self.async_write_ha_state() + def _update_state(self, data): + """Update the state of the entity.""" + self._attr_is_on = data[f"pwr{self._slot}"] async def _async_send_packet(self, packet): """Send a packet to the device.""" diff --git a/homeassistant/components/broadlink/translations/de.json b/homeassistant/components/broadlink/translations/de.json index d81c131bf5d..1e5635b3145 100644 --- a/homeassistant/components/broadlink/translations/de.json +++ b/homeassistant/components/broadlink/translations/de.json @@ -4,13 +4,13 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "not_supported": "Ger\u00e4t nicht unterst\u00fctzt", "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, "flow_title": "{name} ({model} unter {host})", @@ -32,7 +32,7 @@ "data": { "unlock": "Ja mach das." }, - "description": "{name} ({model} unter {host}) ist gesperrt. Dies kann zu Authentifizierungsproblemen im Home Assistant f\u00fchren. M\u00f6chten Sie es entsperren?", + "description": "{name} ({model} unter {host}) ist gesperrt. Dies kann zu Authentifizierungsproblemen im Home Assistant f\u00fchren. M\u00f6chtest du es entsperren?", "title": "Entsperren des Ger\u00e4ts (optional)" }, "user": { diff --git a/homeassistant/components/broadlink/translations/hu.json b/homeassistant/components/broadlink/translations/hu.json index 90213e99aec..8b8dce984e5 100644 --- a/homeassistant/components/broadlink/translations/hu.json +++ b/homeassistant/components/broadlink/translations/hu.json @@ -5,6 +5,7 @@ "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_host": "\u00c9rv\u00e9nytelen hosztn\u00e9v vagy IP-c\u00edm", + "not_supported": "Az eszk\u00f6z nem t\u00e1mogatott", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index c0021df11fc..95ffcf063f2 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -3,15 +3,11 @@ from __future__ import annotations from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - DEVICE_CLASS_TIMESTAMP, - PERCENTAGE, +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, ) - -from .model import SensorDescription +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE ATTR_BELT_UNIT_REMAINING_LIFE: Final = "belt_unit_remaining_life" ATTR_BLACK_DRUM_COUNTER: Final = "black_drum_counter" @@ -31,9 +27,7 @@ ATTR_DRUM_COUNTER: Final = "drum_counter" ATTR_DRUM_REMAINING_LIFE: Final = "drum_remaining_life" ATTR_DRUM_REMAINING_PAGES: Final = "drum_remaining_pages" ATTR_DUPLEX_COUNTER: Final = "duplex_unit_pages_counter" -ATTR_ENABLED: Final = "enabled" ATTR_FUSER_REMAINING_LIFE: Final = "fuser_remaining_life" -ATTR_LABEL: Final = "label" ATTR_LASER_REMAINING_LIFE: Final = "laser_remaining_life" ATTR_MAGENTA_DRUM_COUNTER: Final = "magenta_drum_counter" ATTR_MAGENTA_DRUM_REMAINING_LIFE: Final = "magenta_drum_remaining_life" @@ -46,7 +40,6 @@ ATTR_PF_KIT_1_REMAINING_LIFE: Final = "pf_kit_1_remaining_life" ATTR_PF_KIT_MP_REMAINING_LIFE: Final = "pf_kit_mp_remaining_life" ATTR_REMAINING_PAGES: Final = "remaining_pages" ATTR_STATUS: Final = "status" -ATTR_UNIT: Final = "unit" ATTR_UPTIME: Final = "uptime" ATTR_YELLOW_DRUM_COUNTER: Final = "yellow_drum_counter" ATTR_YELLOW_DRUM_REMAINING_LIFE: Final = "yellow_drum_remaining_life" @@ -84,174 +77,170 @@ ATTRS_MAP: Final[dict[str, tuple[str, str]]] = { ), } -SENSOR_TYPES: Final[dict[str, SensorDescription]] = { - ATTR_STATUS: { - ATTR_ICON: "mdi:printer", - ATTR_LABEL: ATTR_STATUS.title(), - ATTR_UNIT: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: None, - }, - ATTR_PAGE_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_PAGE_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BW_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_BW_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_COLOR_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DUPLEX_COUNTER: { - ATTR_ICON: "mdi:file-document-outline", - ATTR_LABEL: ATTR_DUPLEX_COUNTER.replace("_", " ").title(), - ATTR_UNIT: UNIT_PAGES, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_DRUM_REMAINING_LIFE: { - ATTR_ICON: "mdi:chart-donut", - ATTR_LABEL: ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BELT_UNIT_REMAINING_LIFE: { - ATTR_ICON: "mdi:current-ac", - ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_FUSER_REMAINING_LIFE: { - ATTR_ICON: "mdi:water-outline", - ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_LASER_REMAINING_LIFE: { - ATTR_ICON: "mdi:spotlight-beam", - ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_PF_KIT_1_REMAINING_LIFE: { - ATTR_ICON: "mdi:printer-3d", - ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_PF_KIT_MP_REMAINING_LIFE: { - ATTR_ICON: "mdi:printer-3d", - ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_TONER_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BLACK_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_CYAN_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MAGENTA_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_YELLOW_INK_REMAINING: { - ATTR_ICON: "mdi:printer-3d-nozzle", - ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: PERCENTAGE, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_UPTIME: { - ATTR_ICON: None, - ATTR_LABEL: ATTR_UPTIME.title(), - ATTR_UNIT: None, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - }, -} +SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key=ATTR_STATUS, + icon="mdi:printer", + name=ATTR_STATUS.title(), + ), + SensorEntityDescription( + key=ATTR_PAGE_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_PAGE_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BW_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_BW_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_COLOR_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_COLOR_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DUPLEX_COUNTER, + icon="mdi:file-document-outline", + name=ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + unit_of_measurement=UNIT_PAGES, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BLACK_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_CYAN_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_MAGENTA_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_YELLOW_DRUM_REMAINING_LIFE, + icon="mdi:chart-donut", + name=ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BELT_UNIT_REMAINING_LIFE, + icon="mdi:current-ac", + name=ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_FUSER_REMAINING_LIFE, + icon="mdi:water-outline", + name=ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_LASER_REMAINING_LIFE, + icon="mdi:spotlight-beam", + name=ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_PF_KIT_1_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_PF_KIT_MP_REMAINING_LIFE, + icon="mdi:printer-3d", + name=ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BLACK_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_CYAN_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_MAGENTA_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_YELLOW_TONER_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BLACK_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_CYAN_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_MAGENTA_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_YELLOW_INK_REMAINING, + icon="mdi:printer-3d-nozzle", + name=ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_UPTIME, + name=ATTR_UPTIME.title(), + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), +) diff --git a/homeassistant/components/brother/model.py b/homeassistant/components/brother/model.py deleted file mode 100644 index ab8df09b749..00000000000 --- a/homeassistant/components/brother/model.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Type definitions for Brother integration.""" -from __future__ import annotations - -from typing import TypedDict - - -class SensorDescription(TypedDict, total=False): - """Sensor description class.""" - - icon: str | None - label: str - unit: str | None - enabled: bool - state_class: str | None - device_class: str | None diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 38fac529076..0ff5c14d9cc 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -1,24 +1,21 @@ """Support for the Brother service.""" from __future__ import annotations -from typing import Any +from typing import Any, cast -from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import BrotherDataUpdateCoordinator from .const import ( ATTR_COUNTER, - ATTR_ENABLED, - ATTR_LABEL, ATTR_MANUFACTURER, ATTR_REMAINING_PAGES, - ATTR_UNIT, ATTR_UPTIME, ATTRS_MAP, DATA_CONFIG_ENTRY, @@ -43,9 +40,9 @@ async def async_setup_entry( "sw_version": getattr(coordinator.data, "firmware", None), } - for sensor in SENSOR_TYPES: - if sensor in coordinator.data: - sensors.append(BrotherPrinterSensor(coordinator, sensor, device_info)) + for description in SENSOR_TYPES: + if description.key in coordinator.data: + sensors.append(BrotherPrinterSensor(coordinator, description, device_info)) async_add_entities(sensors, False) @@ -55,34 +52,35 @@ class BrotherPrinterSensor(CoordinatorEntity, SensorEntity): def __init__( self, coordinator: BrotherDataUpdateCoordinator, - kind: str, + description: SensorEntityDescription, device_info: DeviceInfo, ) -> None: """Initialize.""" super().__init__(coordinator) - description = SENSOR_TYPES[kind] self._attrs: dict[str, Any] = {} - self._attr_device_class = description.get(ATTR_DEVICE_CLASS) self._attr_device_info = device_info - self._attr_entity_registry_enabled_default = description[ATTR_ENABLED] - self._attr_icon = description[ATTR_ICON] - self._attr_name = f"{coordinator.data.model} {description[ATTR_LABEL]}" - self._attr_state_class = description[ATTR_STATE_CLASS] - self._attr_unique_id = f"{coordinator.data.serial.lower()}_{kind}" - self._attr_unit_of_measurement = description[ATTR_UNIT] - self.kind = kind + self._attr_name = f"{coordinator.data.model} {description.name}" + self._attr_unique_id = f"{coordinator.data.serial.lower()}_{description.key}" + self.entity_description = description @property - def state(self) -> Any: + def state(self) -> StateType: """Return the state.""" - if self.kind == ATTR_UPTIME: - return getattr(self.coordinator.data, self.kind).isoformat() - return getattr(self.coordinator.data, self.kind) + if self.entity_description.key == ATTR_UPTIME: + return cast( + StateType, + getattr(self.coordinator.data, self.entity_description.key).isoformat(), + ) + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.key) + ) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - remaining_pages, drum_counter = ATTRS_MAP.get(self.kind, (None, None)) + remaining_pages, drum_counter = ATTRS_MAP.get( + self.entity_description.key, (None, None) + ) if remaining_pages and drum_counter: self._attrs[ATTR_REMAINING_PAGES] = getattr( self.coordinator.data, remaining_pages diff --git a/homeassistant/components/brother/translations/de.json b/homeassistant/components/brother/translations/de.json index 3390ca6ca8f..8126a04f21d 100644 --- a/homeassistant/components/brother/translations/de.json +++ b/homeassistant/components/brother/translations/de.json @@ -1,13 +1,13 @@ { "config": { "abort": { - "already_configured": "Dieser Drucker ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unsupported_model": "Dieses Druckermodell wird nicht unterst\u00fctzt." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", "snmp_error": "SNMP-Server deaktiviert oder Drucker nicht unterst\u00fctzt.", - "wrong_host": " Ung\u00fcltiger Hostname oder IP-Adresse" + "wrong_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, "flow_title": "{model} {serial_number}", "step": { @@ -22,7 +22,7 @@ "data": { "type": "Typ des Druckers" }, - "description": "M\u00f6chten Sie den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", + "description": "M\u00f6chtest du den Brother Drucker {model} mit der Seriennummer `{serial_number}` zum Home Assistant hinzuf\u00fcgen?", "title": "Brother-Drucker entdeckt" } } diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 32af96dfe60..c327f9122ce 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -82,25 +82,8 @@ class BrottsplatskartanSensor(SensorEntity): def __init__(self, bpk, name): """Initialize the Brottsplatskartan sensor.""" - self._attributes = {} self._brottsplatskartan = bpk - self._name = name - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes + self._attr_name = name def update(self): """Update device state.""" @@ -116,6 +99,8 @@ class BrottsplatskartanSensor(SensorEntity): incident_type = incident.get("title_type") incident_counts[incident_type] += 1 - self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION} - self._attributes.update(incident_counts) - self._state = len(incidents) + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION + } + self._attr_extra_state_attributes.update(incident_counts) + self._attr_state = len(incidents) diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 9c539fe51fe..5c9d7b3d4a5 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,4 +1,5 @@ """Support for Brunt Blind Engine covers.""" +from __future__ import annotations import logging @@ -69,37 +70,19 @@ class BruntDevice(CoverEntity): Contains the common logic for all Brunt devices. """ + _attr_device_class = DEVICE_CLASS_WINDOW + _attr_supported_features = COVER_FEATURES + def __init__(self, bapi, name, thing_uri): """Init the Brunt device.""" self._bapi = bapi - self._name = name + self._attr_name = name self._thing_uri = thing_uri self._state = {} - self._available = None @property - def name(self): - """Return the name of the device as reported by tellcore.""" - return self._name - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available - - @property - def current_cover_position(self): - """ - Return current position of cover. - - None is unknown, 0 is closed, 100 is fully open. - """ - pos = self._state.get("currentPosition") - return int(pos) if pos else None - - @property - def request_cover_position(self): + def request_cover_position(self) -> int | None: """ Return request position of cover. @@ -111,7 +94,7 @@ class BruntDevice(CoverEntity): return int(pos) if pos else None @property - def move_state(self): + def move_state(self) -> int | None: """ Return current moving state of cover. @@ -120,47 +103,23 @@ class BruntDevice(CoverEntity): mov = self._state.get("moveState") return int(mov) if mov else None - @property - def is_opening(self): - """Return if the cover is opening or not.""" - return self.move_state == 1 - - @property - def is_closing(self): - """Return if the cover is closing or not.""" - return self.move_state == 2 - - @property - def extra_state_attributes(self): - """Return the detailed device state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_REQUEST_POSITION: self.request_cover_position, - } - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_WINDOW - - @property - def supported_features(self): - """Flag supported features.""" - return COVER_FEATURES - - @property - def is_closed(self): - """Return true if cover is closed, else False.""" - return self.current_cover_position == CLOSED_POSITION - def update(self): """Poll the current state of the device.""" try: self._state = self._bapi.getState(thingUri=self._thing_uri).get("thing") - self._available = True + self._attr_available = True except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) - self._available = False + self._attr_available = False + self._attr_is_opening = self.move_state == 1 + self._attr_is_closing = self.move_state == 2 + pos = self._state.get("currentPosition") + self._attr_current_cover_position = int(pos) if pos else None + self._attr_is_closed = self.current_cover_position == CLOSED_POSITION + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_REQUEST_POSITION: self.request_cover_position, + } def open_cover(self, **kwargs): """Set the cover to the open position.""" diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index d6ec805af55..160c4f9d9b3 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -27,7 +27,6 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -88,6 +87,10 @@ async def async_setup_entry( class BSBLanClimate(ClimateEntity): """Defines a BSBLan climate device.""" + _attr_supported_features = SUPPORT_FLAGS + _attr_hvac_modes = HVAC_MODES + _attr_preset_modes = PRESET_MODES + def __init__( self, entry_id: str, @@ -95,89 +98,33 @@ class BSBLanClimate(ClimateEntity): info: Info, ) -> None: """Initialize BSBLan climate device.""" - self._current_temperature: float | None = None - self._available = True - self._hvac_mode: str | None = None - self._target_temperature: float | None = None - self._temperature_unit = None - self._preset_mode = None + self._attr_available = True self._store_hvac_mode = None - self._info: Info = info self.bsblan = bsblan - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._info.device_identification - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return self._info.device_identification - - @property - def temperature_unit(self) -> str: - """Return the unit of measurement which this thermostat uses.""" - if self._temperature_unit == "°C": - return TEMP_CELSIUS - return TEMP_FAHRENHEIT - - @property - def supported_features(self) -> int: - """Flag supported features.""" - return SUPPORT_FLAGS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def hvac_mode(self): - """Return the current operation mode.""" - return self._hvac_mode - - @property - def hvac_modes(self): - """Return the list of available operation modes.""" - return HVAC_MODES - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_modes(self): - """List of available preset modes.""" - return PRESET_MODES - - @property - def preset_mode(self): - """Return the preset_mode.""" - return self._preset_mode + self._attr_name = self._attr_unique_id = info.device_identification + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, info.device_identification)}, + ATTR_NAME: "BSBLan Device", + ATTR_MANUFACTURER: "BSBLan", + ATTR_MODEL: info.controller_variant, + } async def async_set_preset_mode(self, preset_mode): """Set preset mode.""" _LOGGER.debug("Setting preset mode to: %s", preset_mode) if preset_mode == PRESET_NONE: # restore previous hvac mode - self._hvac_mode = self._store_hvac_mode + self._attr_hvac_mode = self._store_hvac_mode else: # Store hvac mode. - self._store_hvac_mode = self._hvac_mode + self._store_hvac_mode = self._attr_hvac_mode await self.async_set_data(preset_mode=preset_mode) async def async_set_hvac_mode(self, hvac_mode): """Set HVAC mode.""" _LOGGER.debug("Setting HVAC mode to: %s", hvac_mode) # preset should be none when hvac mode is set - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE await self.async_set_data(hvac_mode=hvac_mode) async def async_set_temperature(self, **kwargs): @@ -204,39 +151,33 @@ class BSBLanClimate(ClimateEntity): await self.bsblan.thermostat(**data) except BSBLanError: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False async def async_update(self) -> None: """Update BSBlan entity.""" try: state: State = await self.bsblan.state() except BSBLanError: - if self._available: + if self.available: _LOGGER.error("An error occurred while updating the BSBLan device") - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True - self._current_temperature = float(state.current_temperature.value) - self._target_temperature = float(state.target_temperature.value) + self._attr_current_temperature = float(state.current_temperature.value) + self._attr_target_temperature = float(state.target_temperature.value) # check if preset is active else get hvac mode _LOGGER.debug("state hvac/preset mode: %s", state.hvac_mode.value) if state.hvac_mode.value == "2": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO else: - self._hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] - self._preset_mode = PRESET_NONE + self._attr_hvac_mode = BSBLAN_TO_HA_STATE[state.hvac_mode.value] + self._attr_preset_mode = PRESET_NONE - self._temperature_unit = state.current_temperature.unit - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this BSBLan device.""" - return { - ATTR_IDENTIFIERS: {(DOMAIN, self._info.device_identification)}, - ATTR_NAME: "BSBLan Device", - ATTR_MANUFACTURER: "BSBLan", - ATTR_MODEL: self._info.controller_variant, - } + self._attr_temperature_unit = ( + TEMP_CELSIUS + if state.current_temperature.unit == "°C" + else TEMP_FAHRENHEIT + ) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 88866b27801..dff77739106 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from bsblan import BSBLan, BSBLanError, Info import voluptuous as vol @@ -10,7 +11,6 @@ from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import CONF_DEVICE_IDENT, CONF_PASSKEY, DOMAIN @@ -22,7 +22,9 @@ class BSBLanFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form() diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 1dd461e2081..e65100af90d 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -1,26 +1,27 @@ """Constants for the BSB-Lan integration.""" +from typing import Final DOMAIN = "bsblan" -DATA_BSBLAN_CLIENT = "bsblan_client" -DATA_BSBLAN_TIMER = "bsblan_timer" -DATA_BSBLAN_UPDATED = "bsblan_updated" +DATA_BSBLAN_CLIENT: Final = "bsblan_client" +DATA_BSBLAN_TIMER: Final = "bsblan_timer" +DATA_BSBLAN_UPDATED: Final = "bsblan_updated" -ATTR_IDENTIFIERS = "identifiers" -ATTR_MODEL = "model" -ATTR_MANUFACTURER = "manufacturer" +ATTR_IDENTIFIERS: Final = "identifiers" +ATTR_MODEL: Final = "model" +ATTR_MANUFACTURER: Final = "manufacturer" -ATTR_TARGET_TEMPERATURE = "target_temperature" -ATTR_INSIDE_TEMPERATURE = "inside_temperature" -ATTR_OUTSIDE_TEMPERATURE = "outside_temperature" +ATTR_TARGET_TEMPERATURE: Final = "target_temperature" +ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" +ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" -ATTR_STATE_ON = "on" -ATTR_STATE_OFF = "off" +ATTR_STATE_ON: Final = "on" +ATTR_STATE_OFF: Final = "off" -CONF_DEVICE_IDENT = "device_identification" -CONF_CONTROLLER_FAM = "controller_family" -CONF_CONTROLLER_VARI = "controller_variant" +CONF_DEVICE_IDENT: Final = "device_identification" +CONF_CONTROLLER_FAM: Final = "controller_family" +CONF_CONTROLLER_VARI: Final = "controller_variant" -SENSOR_TYPE_TEMPERATURE = "temperature" +SENSOR_TYPE_TEMPERATURE: Final = "temperature" -CONF_PASSKEY = "passkey" +CONF_PASSKEY: Final = "passkey" diff --git a/homeassistant/components/bsblan/translations/de.json b/homeassistant/components/bsblan/translations/de.json index ce9d8a0cb00..079749f0f7a 100644 --- a/homeassistant/components/bsblan/translations/de.json +++ b/homeassistant/components/bsblan/translations/de.json @@ -13,7 +13,7 @@ "host": "Host", "passkey": "Passkey String", "password": "Passwort", - "port": "Port Nummer", + "port": "Port", "username": "Benutzername" }, "description": "Richte dein BSB-Lan Ger\u00e4t f\u00fcr die Integration mit dem Home Assistant ein.", diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index 0474876bf2f..d7ec47d2bf8 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -1,46 +1,17 @@ """The buienradar integration.""" from __future__ import annotations -import logging - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_COUNTRY, - CONF_DELTA, - CONF_DIMENSION, - CONF_TIMEFRAME, - DEFAULT_COUNTRY, - DEFAULT_DELTA, - DEFAULT_DIMENSION, - DEFAULT_TIMEFRAME, - DOMAIN, -) +from .const import DOMAIN PLATFORMS = ["camera", "sensor", "weather"] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the buienradar component.""" - hass.data.setdefault(DOMAIN, {}) - - weather_configs = _filter_domain_configs(config, "weather", DOMAIN) - sensor_configs = _filter_domain_configs(config, "sensor", DOMAIN) - camera_configs = _filter_domain_configs(config, "camera", DOMAIN) - - _import_configs(hass, weather_configs, sensor_configs, camera_configs) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up buienradar from a config entry.""" + hass.data.setdefault(DOMAIN, {}) hass.config_entries.async_setup_platforms(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) return True @@ -56,86 +27,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) - - -def _import_configs( - hass: HomeAssistant, - weather_configs: list[ConfigType], - sensor_configs: list[ConfigType], - camera_configs: list[ConfigType], -) -> None: - camera_config = {} - if camera_configs: - camera_config = camera_configs[0] - - for config in sensor_configs: - # Remove weather configurations which share lat/lon with sensor configurations - matching_weather_config = None - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - for weather_config in weather_configs: - weather_latitude = config.get(CONF_LATITUDE, hass.config.latitude) - weather_longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - if latitude == weather_latitude and longitude == weather_longitude: - matching_weather_config = weather_config - break - - if matching_weather_config is not None: - weather_configs.remove(matching_weather_config) - - configs = weather_configs + sensor_configs - - if not configs and camera_configs: - config = { - CONF_LATITUDE: hass.config.latitude, - CONF_LONGITUDE: hass.config.longitude, - } - configs.append(config) - - if configs: - _try_update_unique_id(hass, configs[0], camera_config) - - for config in configs: - data = { - CONF_LATITUDE: config.get(CONF_LATITUDE, hass.config.latitude), - CONF_LONGITUDE: config.get(CONF_LONGITUDE, hass.config.longitude), - CONF_TIMEFRAME: config.get(CONF_TIMEFRAME, DEFAULT_TIMEFRAME), - CONF_COUNTRY: camera_config.get(CONF_COUNTRY, DEFAULT_COUNTRY), - CONF_DELTA: camera_config.get(CONF_DELTA, DEFAULT_DELTA), - CONF_NAME: config.get(CONF_NAME, "Buienradar"), - } - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=data, - ) - ) - - -def _try_update_unique_id( - hass: HomeAssistant, config: ConfigType, camera_config: ConfigType -) -> None: - dimension = camera_config.get(CONF_DIMENSION, DEFAULT_DIMENSION) - country = camera_config.get(CONF_COUNTRY, DEFAULT_COUNTRY) - - registry = entity_registry.async_get(hass) - entity_id = registry.async_get_entity_id("camera", DOMAIN, f"{dimension}_{country}") - - if entity_id is not None: - latitude = config[CONF_LATITUDE] - longitude = config[CONF_LONGITUDE] - - new_unique_id = f"{latitude:2.6f}{longitude:2.6f}" - registry.async_update_entity(entity_id, new_unique_id=new_unique_id) - - -def _filter_domain_configs( - config: ConfigType, domain: str, platform: str -) -> list[ConfigType]: - configs = [] - for entry in config: - if entry.startswith(domain): - configs += [x for x in config[entry] if x["platform"] == platform] - return configs diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 059cd79d522..34f1f173319 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -8,11 +8,10 @@ import logging import aiohttp import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE 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.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -20,7 +19,6 @@ from homeassistant.util import dt as dt_util from .const import ( CONF_COUNTRY, CONF_DELTA, - CONF_DIMENSION, DEFAULT_COUNTRY, DEFAULT_DELTA, DEFAULT_DIMENSION, @@ -34,26 +32,6 @@ DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700)) # Multiple choice for available Radar Map URL SUPPORTED_COUNTRY_CODES = ["NL", "BE"] -PLATFORM_SCHEMA = vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE, - vol.Optional(CONF_DELTA, default=600.0): cv.positive_float, - vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string, - vol.Optional(CONF_COUNTRY, default="NL"): vol.All( - vol.Coerce(str), vol.In(SUPPORTED_COUNTRY_CODES) - ), - } - ) -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar camera platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index e773b39027e..445c6cacbc8 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -1,7 +1,6 @@ """Config flow for buienradar integration.""" from __future__ import annotations -import logging from typing import Any import voluptuous as vol @@ -24,8 +23,6 @@ from .const import ( SUPPORTED_COUNTRY_CODES, ) -_LOGGER = logging.getLogger(__name__) - class BuienradarFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for buienradar.""" @@ -70,18 +67,6 @@ class BuienradarFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors={}, ) - async def async_step_import(self, import_input: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - latitude = import_input[CONF_LATITUDE] - longitude = import_input[CONF_LONGITUDE] - - await self.async_set_unique_id(f"{latitude}-{longitude}") - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=f"{latitude},{longitude}", data=import_input - ) - class BuienradarOptionFlowHandler(config_entries.OptionsFlow): """Handle options.""" diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index cc785512f9b..6af579dd74f 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -7,15 +7,10 @@ DEFAULT_TIMEFRAME = 60 DEFAULT_DIMENSION = 700 DEFAULT_DELTA = 600 -CONF_DIMENSION = "dimension" CONF_DELTA = "delta" CONF_COUNTRY = "country_code" CONF_TIMEFRAME = "timeframe" -"""Range according to the docs""" -CAMERA_DIM_MIN = 120 -CAMERA_DIM_MAX = 700 - SUPPORTED_COUNTRY_CODES = ["NL", "BE"] DEFAULT_COUNTRY = "NL" diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index e4a317cface..7af84f48af7 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -18,17 +18,16 @@ from buienradar.constants import ( WINDGUST, WINDSPEED, ) -import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, + DEVICE_CLASS_TEMPERATURE, IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, @@ -39,7 +38,6 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util @@ -60,159 +58,262 @@ SCHEDULE_NOK = 2 # Supported sensor types: # Key: ['label', unit, icon] SENSOR_TYPES = { - "stationname": ["Stationname", None, None], + "stationname": ["Stationname", None, None, None], # new in json api (>1.0.0): - "barometerfc": ["Barometer value", None, "mdi:gauge"], + "barometerfc": ["Barometer value", None, "mdi:gauge", None], # new in json api (>1.0.0): - "barometerfcname": ["Barometer", None, "mdi:gauge"], + "barometerfcname": ["Barometer", None, "mdi:gauge", None], # new in json api (>1.0.0): - "barometerfcnamenl": ["Barometer", None, "mdi:gauge"], - "condition": ["Condition", None, None], - "conditioncode": ["Condition code", None, None], - "conditiondetailed": ["Detailed condition", None, None], - "conditionexact": ["Full condition", None, None], - "symbol": ["Symbol", None, None], + "barometerfcnamenl": ["Barometer", None, "mdi:gauge", None], + "condition": ["Condition", None, None, None], + "conditioncode": ["Condition code", None, None, None], + "conditiondetailed": ["Detailed condition", None, None, None], + "conditionexact": ["Full condition", None, None, None], + "symbol": ["Symbol", None, None, None], # new in json api (>1.0.0): - "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], - "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent"], - "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], - "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windforce": ["Wind force", "Bft", "mdi:weather-windy"], - "winddirection": ["Wind direction", None, "mdi:compass-outline"], - "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline"], - "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge"], - "visibility": ["Visibility", LENGTH_KILOMETERS, None], - "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "feeltemperature": [ + "Feel temperature", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent", None], + "temperature": [ + "Temperature", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "groundtemperature": [ + "Ground temperature", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], + "windforce": ["Wind force", "Bft", "mdi:weather-windy", None], + "winddirection": ["Wind direction", None, "mdi:compass-outline", None], + "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline", None], + "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge", None], + "visibility": ["Visibility", LENGTH_KILOMETERS, None, None], + "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy", None], "precipitation": [ "Precipitation", PRECIPITATION_MILLIMETERS_PER_HOUR, "mdi:weather-pouring", + None, + ], + "irradiance": [ + "Irradiance", + IRRADIATION_WATTS_PER_SQUARE_METER, + "mdi:sunglasses", + None, ], - "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], "precipitation_forecast_average": [ "Precipitation forecast average", PRECIPITATION_MILLIMETERS_PER_HOUR, "mdi:weather-pouring", + None, ], "precipitation_forecast_total": [ "Precipitation forecast total", LENGTH_MILLIMETERS, "mdi:weather-pouring", + None, ], # new in json api (>1.0.0): - "rainlast24hour": ["Rain last 24h", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + "rainlast24hour": [ + "Rain last 24h", + LENGTH_MILLIMETERS, + "mdi:weather-pouring", + None, + ], # new in json api (>1.0.0): - "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "temperature_1d": ["Temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_2d": ["Temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_3d": ["Temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_4d": ["Temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_5d": ["Temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_1d": ["Minimum temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_2d": ["Minimum temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_3d": ["Minimum temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_4d": ["Minimum temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], - "mintemp_5d": ["Minimum temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], - "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "temperature_1d": [ + "Temperature 1d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "temperature_2d": [ + "Temperature 2d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "temperature_3d": [ + "Temperature 3d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "temperature_4d": [ + "Temperature 4d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "temperature_5d": [ + "Temperature 5d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "mintemp_1d": [ + "Minimum temperature 1d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "mintemp_2d": [ + "Minimum temperature 2d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "mintemp_3d": [ + "Minimum temperature 3d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "mintemp_4d": [ + "Minimum temperature 4d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "mintemp_5d": [ + "Minimum temperature 5d", + TEMP_CELSIUS, + None, + DEVICE_CLASS_TEMPERATURE, + ], + "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], # new in json api (>1.0.0): - "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], + "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], # new in json api (>1.0.0): - "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], - "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring"], - "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring"], - "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy"], - "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"], - "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"], - "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], - "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], - "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], - "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], - "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], - "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], - "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], - "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline"], - "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline"], - "windazimuth_1d": ["Wind direction azimuth 1d", DEGREE, "mdi:compass-outline"], - "windazimuth_2d": ["Wind direction azimuth 2d", DEGREE, "mdi:compass-outline"], - "windazimuth_3d": ["Wind direction azimuth 3d", DEGREE, "mdi:compass-outline"], - "windazimuth_4d": ["Wind direction azimuth 4d", DEGREE, "mdi:compass-outline"], - "windazimuth_5d": ["Wind direction azimuth 5d", DEGREE, "mdi:compass-outline"], - "condition_1d": ["Condition 1d", None, None], - "condition_2d": ["Condition 2d", None, None], - "condition_3d": ["Condition 3d", None, None], - "condition_4d": ["Condition 4d", None, None], - "condition_5d": ["Condition 5d", None, None], - "conditioncode_1d": ["Condition code 1d", None, None], - "conditioncode_2d": ["Condition code 2d", None, None], - "conditioncode_3d": ["Condition code 3d", None, None], - "conditioncode_4d": ["Condition code 4d", None, None], - "conditioncode_5d": ["Condition code 5d", None, None], - "conditiondetailed_1d": ["Detailed condition 1d", None, None], - "conditiondetailed_2d": ["Detailed condition 2d", None, None], - "conditiondetailed_3d": ["Detailed condition 3d", None, None], - "conditiondetailed_4d": ["Detailed condition 4d", None, None], - "conditiondetailed_5d": ["Detailed condition 5d", None, None], - "conditionexact_1d": ["Full condition 1d", None, None], - "conditionexact_2d": ["Full condition 2d", None, None], - "conditionexact_3d": ["Full condition 3d", None, None], - "conditionexact_4d": ["Full condition 4d", None, None], - "conditionexact_5d": ["Full condition 5d", None, None], - "symbol_1d": ["Symbol 1d", None, None], - "symbol_2d": ["Symbol 2d", None, None], - "symbol_3d": ["Symbol 3d", None, None], - "symbol_4d": ["Symbol 4d", None, None], - "symbol_5d": ["Symbol 5d", None, None], + "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring", None], + "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring", None], + "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring", None], + "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring", None], + "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring", None], + "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring", None], + "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy", None], + "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy", None], + "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy", None], + "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy", None], + "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy", None], + "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy", None], + "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy", None], + "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy", None], + "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy", None], + "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy", None], + "windspeed_1d": [ + "Wind speed 1d", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], + "windspeed_2d": [ + "Wind speed 2d", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], + "windspeed_3d": [ + "Wind speed 3d", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], + "windspeed_4d": [ + "Wind speed 4d", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], + "windspeed_5d": [ + "Wind speed 5d", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], + "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline", None], + "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline", None], + "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline", None], + "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline", None], + "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline", None], + "windazimuth_1d": [ + "Wind direction azimuth 1d", + DEGREE, + "mdi:compass-outline", + None, + ], + "windazimuth_2d": [ + "Wind direction azimuth 2d", + DEGREE, + "mdi:compass-outline", + None, + ], + "windazimuth_3d": [ + "Wind direction azimuth 3d", + DEGREE, + "mdi:compass-outline", + None, + ], + "windazimuth_4d": [ + "Wind direction azimuth 4d", + DEGREE, + "mdi:compass-outline", + None, + ], + "windazimuth_5d": [ + "Wind direction azimuth 5d", + DEGREE, + "mdi:compass-outline", + None, + ], + "condition_1d": ["Condition 1d", None, None, None], + "condition_2d": ["Condition 2d", None, None, None], + "condition_3d": ["Condition 3d", None, None, None], + "condition_4d": ["Condition 4d", None, None, None], + "condition_5d": ["Condition 5d", None, None, None], + "conditioncode_1d": ["Condition code 1d", None, None, None], + "conditioncode_2d": ["Condition code 2d", None, None, None], + "conditioncode_3d": ["Condition code 3d", None, None, None], + "conditioncode_4d": ["Condition code 4d", None, None, None], + "conditioncode_5d": ["Condition code 5d", None, None, None], + "conditiondetailed_1d": ["Detailed condition 1d", None, None, None], + "conditiondetailed_2d": ["Detailed condition 2d", None, None, None], + "conditiondetailed_3d": ["Detailed condition 3d", None, None, None], + "conditiondetailed_4d": ["Detailed condition 4d", None, None, None], + "conditiondetailed_5d": ["Detailed condition 5d", None, None, None], + "conditionexact_1d": ["Full condition 1d", None, None, None], + "conditionexact_2d": ["Full condition 2d", None, None, None], + "conditionexact_3d": ["Full condition 3d", None, None, None], + "conditionexact_4d": ["Full condition 4d", None, None, None], + "conditionexact_5d": ["Full condition 5d", None, None, None], + "symbol_1d": ["Symbol 1d", None, None, None], + "symbol_2d": ["Symbol 2d", None, None, None], + "symbol_3d": ["Symbol 3d", None, None, None], + "symbol_4d": ["Symbol 4d", None, None, None], + "symbol_5d": ["Symbol 5d", None, None, None], } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional( - CONF_MONITORED_CONDITIONS, default=["symbol", "temperature"] - ): vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]), - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - vol.Optional(CONF_TIMEFRAME, default=DEFAULT_TIMEFRAME): vol.All( - vol.Coerce(int), vol.Range(min=5, max=120) - ), - vol.Optional(CONF_NAME, default="br"): cv.string, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar sensor platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -255,32 +356,29 @@ async def async_setup_entry( class BrSensor(SensorEntity): """Representation of an Buienradar sensor.""" + _attr_entity_registry_enabled_default = False + _attr_should_poll = False + def __init__(self, sensor_type, client_name, coordinates): """Initialize the sensor.""" - self.client_name = client_name - self._name = SENSOR_TYPES[sensor_type][0] + self._attr_name = f"{client_name} {SENSOR_TYPES[sensor_type][0]}" + self._attr_icon = SENSOR_TYPES[sensor_type][2] self.type = sensor_type - self._state = None - self._unit_of_measurement = SENSOR_TYPES[self.type][1] - self._entity_picture = None - self._attribution = None + self._attr_unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._measured = None - self._stationname = None - self._unique_id = self.uid(coordinates) + self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( + coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], sensor_type + ) + self._attr_device_class = SENSOR_TYPES[sensor_type][3] # All continuous sensors should be forced to be updated - self._force_update = self.type != SYMBOL and not self.type.startswith(CONDITION) - - if self.type.startswith(PRECIPITATION_FORECAST): - self._timeframe = None - - def uid(self, coordinates): - """Generate a unique id using coordinates and sensor type.""" - # The combination of the location, name and sensor type is unique - return "{:2.6f}{:2.6f}{}".format( - coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], self.type + self._attr_force_update = sensor_type != SYMBOL and not sensor_type.startswith( + CONDITION ) + if sensor_type.startswith(PRECIPITATION_FORECAST): + self._timeframe = None + @callback def data_updated(self, data): """Update data.""" @@ -297,8 +395,6 @@ class BrSensor(SensorEntity): if self._measured == data.get(MEASURED): return False - self._attribution = data.get(ATTRIBUTION) - self._stationname = data.get(STATIONNAME) self._measured = data.get(MEASURED) if ( @@ -341,18 +437,18 @@ class BrSensor(SensorEntity): img = condition.get(IMAGE) - if new_state != self._state or img != self._entity_picture: - self._state = new_state - self._entity_picture = img + if new_state != self.state or img != self.entity_picture: + self._attr_state = new_state + self._attr_entity_picture = img return True return False if self.type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: - self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) - if self._state is not None: - self._state = round(self._state * 3.6, 1) + self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + if self.state is not None: + self._attr_state = round(self.state * 3.6, 1) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -360,7 +456,7 @@ class BrSensor(SensorEntity): # update all other sensors try: - self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + self._attr_state = data.get(FORECAST)[fcday].get(self.type[:-3]) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s", fcday) @@ -383,9 +479,9 @@ class BrSensor(SensorEntity): img = condition.get(IMAGE) - if new_state != self._state or img != self._entity_picture: - self._state = new_state - self._entity_picture = img + if new_state != self.state or img != self.entity_picture: + self._attr_state = new_state + self._attr_entity_picture = img return True return False @@ -394,94 +490,40 @@ class BrSensor(SensorEntity): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - self._state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) + self._attr_state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) return True if self.type in [WINDSPEED, WINDGUST]: # hass wants windspeeds in km/h not m/s, so convert: - self._state = data.get(self.type) - if self._state is not None: - self._state = round(data.get(self.type) * 3.6, 1) + self._attr_state = data.get(self.type) + if self.state is not None: + self._attr_state = round(data.get(self.type) * 3.6, 1) return True if self.type == VISIBILITY: # hass wants visibility in km (not m), so convert: - self._state = data.get(self.type) - if self._state is not None: - self._state = round(self._state / 1000, 1) + self._attr_state = data.get(self.type) + if self.state is not None: + self._attr_state = round(self.state / 1000, 1) return True # update all other sensors - self._state = data.get(self.type) - return True - - @property - def attribution(self): - """Return the attribution.""" - return self._attribution - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def entity_picture(self): - """Weather symbol if type is symbol.""" - return self._entity_picture - - @property - def extra_state_attributes(self): - """Return the state attributes.""" + self._attr_state = data.get(self.type) if self.type.startswith(PRECIPITATION_FORECAST): - result = {ATTR_ATTRIBUTION: self._attribution} + result = {ATTR_ATTRIBUTION: data.get(ATTRIBUTION)} if self._timeframe is not None: result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) - return result + self._attr_extra_state_attributes = result result = { - ATTR_ATTRIBUTION: self._attribution, - SENSOR_TYPES["stationname"][0]: self._stationname, + ATTR_ATTRIBUTION: data.get(ATTRIBUTION), + SENSOR_TYPES["stationname"][0]: data.get(STATIONNAME), } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime local_dt = dt_util.as_local(self._measured) result[MEASURED_LABEL] = local_dt.strftime("%c") - return result - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return possible sensor specific icon.""" - return SENSOR_TYPES[self.type][2] - - @property - def force_update(self): - """Return true for continuous sensors, false for discrete sensors.""" - return self._force_update - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False + self._attr_extra_state_attributes = result + return True diff --git a/homeassistant/components/buienradar/translations/fr.json b/homeassistant/components/buienradar/translations/fr.json index d9c2fadcbf7..19b7737ae11 100644 --- a/homeassistant/components/buienradar/translations/fr.json +++ b/homeassistant/components/buienradar/translations/fr.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/buienradar/translations/hu.json b/homeassistant/components/buienradar/translations/hu.json new file mode 100644 index 00000000000..a064fa943a8 --- /dev/null +++ b/homeassistant/components/buienradar/translations/hu.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "country_code": "A kamera k\u00e9peinek megjelen\u00edt\u00e9s\u00e9hez az orsz\u00e1g k\u00f3dja.", + "delta": "A kamera k\u00e9pfriss\u00edt\u00e9s\u00e9nek id\u0151tartama m\u00e1sodpercekben", + "timeframe": "Percek, hogy el\u0151retekints\u00fcnk a csapad\u00e9k el\u0151rejelz\u00e9s\u00e9re" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/zh-Hans.json b/homeassistant/components/buienradar/translations/id.json similarity index 71% rename from homeassistant/components/garmin_connect/translations/zh-Hans.json rename to homeassistant/components/buienradar/translations/id.json index a5f4ff11f09..194ecb51c12 100644 --- a/homeassistant/components/garmin_connect/translations/zh-Hans.json +++ b/homeassistant/components/buienradar/translations/id.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "username": "\u7528\u6237\u540d" + "longitude": "Bujur" } } } diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 83c511713d0..8934a7a6833 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -99,7 +99,7 @@ class BrData: return result except (asyncio.TimeoutError, aiohttp.ClientError) as err: - result[MESSAGE] = "%s" % err + result[MESSAGE] = str(err) return result finally: if resp is not None: diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 0aa57efc5f9..a1546120064 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -11,7 +11,6 @@ from buienradar.constants import ( WINDAZIMUTH, WINDSPEED, ) -import voluptuous as vol from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, @@ -35,13 +34,11 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, - PLATFORM_SCHEMA, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback # Reuse data and API logic from the sensor implementation @@ -76,22 +73,6 @@ CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: (), } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_FORECAST, default=True): cv.boolean, - } -) - - -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up buienradar weather platform.""" - _LOGGER.warning( - "Platform configuration is deprecated, will be removed in a future release" - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -130,12 +111,17 @@ async def async_setup_entry( class BrWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_temperature_unit = TEMP_CELSIUS + def __init__(self, data, config, coordinates): - """Initialise the platform with a data instance and station name.""" + """Initialize the platform with a data instance and station name.""" self._stationname = config.get(CONF_NAME, "Buienradar") + self._attr_name = ( + self._stationname or f"BR {data.stationname or '(unknown station)'}" + ) self._data = data - self._unique_id = "{:2.6f}{:2.6f}".format( + self._attr_unique_id = "{:2.6f}{:2.6f}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE] ) @@ -144,13 +130,6 @@ class BrWeather(WeatherEntity): """Return the attribution.""" return self._data.attribution - @property - def name(self): - """Return the name of the sensor.""" - return ( - self._stationname or f"BR {self._data.stationname or '(unknown station)'}" - ) - @property def condition(self): """Return the current condition.""" @@ -195,11 +174,6 @@ class BrWeather(WeatherEntity): """Return the current wind bearing (degrees).""" return self._data.wind_bearing - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def forecast(self): """Return the forecast array.""" @@ -226,8 +200,3 @@ class BrWeather(WeatherEntity): fcdata_out.append(data_out) return fcdata_out - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 61186249c51..d27555beb2c 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -119,24 +119,13 @@ class WebDavCalendarEventDevice(CalendarEventDevice): self.data = WebDavCalendarData(calendar, days, all_day, search) self.entity_id = entity_id self._event = None - self._name = name - self._offset_reached = False - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return {"offset_reached": self._offset_reached} + self._attr_name = name @property def event(self): """Return the next upcoming event.""" return self._event - @property - def name(self): - """Return the name of the entity.""" - return self._name - async def async_get_events(self, hass, start_date, end_date): """Get all events in a specific time frame.""" return await self.data.async_get_events(hass, start_date, end_date) @@ -149,8 +138,8 @@ class WebDavCalendarEventDevice(CalendarEventDevice): self._event = event return event = calculate_offset(event, OFFSET) - self._offset_reached = is_offset_reached(event) self._event = event + self._attr_extra_state_attributes = {"offset_reached": is_offset_reached(event)} class WebDavCalendarData: diff --git a/homeassistant/components/calendar/translations/he.json b/homeassistant/components/calendar/translations/he.json index 206528ef6a8..9a633670698 100644 --- a/homeassistant/components/calendar/translations/he.json +++ b/homeassistant/components/calendar/translations/he.json @@ -2,8 +2,8 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05dc\u05d5\u05bc\u05d7\u05b7 \u05e9\u05c1\u05b8\u05e0\u05b8\u05d4" + "title": "\u05dc\u05d5\u05d7 \u05e9\u05e0\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4791a963048..d1f354cc78e 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -6,6 +6,7 @@ import base64 import collections from collections.abc import Awaitable, Mapping from contextlib import suppress +from dataclasses import dataclass from datetime import datetime, timedelta import hashlib import logging @@ -46,7 +47,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity, entity_sources +from homeassistant.helpers.entity import Entity, EntityDescription, entity_sources from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType @@ -117,6 +118,11 @@ SCHEMA_WS_CAMERA_THUMBNAIL: Final = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.ex ) +@dataclass +class CameraEntityDescription(EntityDescription): + """A class that describes camera entities.""" + + @attr.s class Image: """Represent an image.""" diff --git a/homeassistant/components/camera/translations/he.json b/homeassistant/components/camera/translations/he.json index ca6b207762d..b3e16e70826 100644 --- a/homeassistant/components/camera/translations/he.json +++ b/homeassistant/components/camera/translations/he.json @@ -1,7 +1,7 @@ { "state": { "_": { - "idle": "\u05de\u05d7\u05db\u05d4", + "idle": "\u05de\u05de\u05ea\u05d9\u05df", "recording": "\u05de\u05e7\u05dc\u05d9\u05d8", "streaming": "\u05de\u05d6\u05e8\u05d9\u05dd" } diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 4e29c40f49f..4d29d4893e7 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -52,6 +52,9 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): """Representation of a Canary alarm control panel.""" coordinator: CanaryDataUpdateCoordinator + _attr_supported_features = ( + SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT + ) def __init__( self, coordinator: CanaryDataUpdateCoordinator, location: Location @@ -59,23 +62,14 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): """Initialize a Canary security camera.""" super().__init__(coordinator) self._location_id: str = location.location_id - self._location_name: str = location.name + self._attr_name = location.name + self._attr_unique_id = str(self._location_id) @property def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] - @property - def name(self) -> str: - """Return the name of the alarm.""" - return self._location_name - - @property - def unique_id(self) -> str: - """Return the unique ID of the alarm.""" - return str(self._location_id) - @property def state(self) -> str | None: """Return the state of the device.""" @@ -92,11 +86,6 @@ class CanaryAlarm(CoordinatorEntity, AlarmControlPanelEntity): return None - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index b1725945db2..2699ba1f640 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -21,7 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import Throttle @@ -30,7 +29,6 @@ from .const import ( CONF_FFMPEG_ARGUMENTS, DATA_COORDINATOR, DEFAULT_FFMPEG_ARGUMENTS, - DEFAULT_TIMEOUT, DOMAIN, MANUFACTURER, ) @@ -73,7 +71,6 @@ async def async_setup_entry( coordinator, location_id, device, - DEFAULT_TIMEOUT, ffmpeg_arguments, ) ) @@ -92,7 +89,6 @@ class CanaryCamera(CoordinatorEntity, Camera): coordinator: CanaryDataUpdateCoordinator, location_id: str, device: Device, - timeout: int, ffmpeg_args: str, ) -> None: """Initialize a Canary security camera.""" @@ -102,37 +98,21 @@ class CanaryCamera(CoordinatorEntity, Camera): self._ffmpeg_arguments = ffmpeg_args self._location_id = location_id self._device = device - self._device_id: str = device.device_id - self._device_name: str = device.name - self._device_type_name = device.device_type["name"] - self._timeout = timeout self._live_stream_session: LiveStreamSession | None = None + self._attr_name = device.name + self._attr_unique_id = str(device.device_id) + self._attr_device_info = { + "identifiers": {(DOMAIN, str(device.device_id))}, + "name": device.name, + "model": device.device_type["name"], + "manufacturer": MANUFACTURER, + } @property def location(self) -> Location: """Return information about the location.""" return self.coordinator.data["locations"][self._location_id] - @property - def name(self) -> str: - """Return the name of this device.""" - return self._device_name - - @property - def unique_id(self) -> str: - """Return the unique ID of this camera.""" - return str(self._device_id) - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - @property def is_recording(self) -> bool: """Return true if the device is recording.""" diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index ac779a9cb69..967273a0f34 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Final +from typing import Any, Final from canary.api import Api from requests.exceptions import ConnectTimeout, HTTPError @@ -24,7 +24,7 @@ from .const import ( _LOGGER: Final = logging.getLogger(__name__) -def validate_input(hass: HomeAssistant, data: ConfigType) -> bool: +def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -56,7 +56,9 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by configuration file.""" return await self.async_step_user(user_input) - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -104,7 +106,9 @@ class CanaryOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage Canary options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 91dc3bad5eb..5c92f0089f2 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -97,11 +96,9 @@ class CanarySensor(CoordinatorEntity, SensorEntity): super().__init__(coordinator) self._sensor_type = sensor_type self._device_id = device.device_id - self._device_name = device.name - self._device_type_name = device.device_type["name"] sensor_type_name = sensor_type[0].replace("_", " ").title() - self._name = f"{location.name} {device.name} {sensor_type_name}" + self._attr_name = f"{location.name} {device.name} {sensor_type_name}" canary_sensor_type = None if self._sensor_type[0] == "air_quality": @@ -116,6 +113,17 @@ class CanarySensor(CoordinatorEntity, SensorEntity): canary_sensor_type = SensorType.BATTERY self._canary_type = canary_sensor_type + self._attr_state = self.reading + self._attr_unique_id = f"{device.device_id}_{sensor_type[0]}" + self._attr_device_info = { + "identifiers": {(DOMAIN, str(device.device_id))}, + "name": device.name, + "model": device.device_type["name"], + "manufacturer": MANUFACTURER, + } + self._attr_unit_of_measurement = sensor_type[1] + self._attr_device_class = sensor_type[3] + self._attr_icon = sensor_type[2] @property def reading(self) -> float | None: @@ -136,46 +144,6 @@ class CanarySensor(CoordinatorEntity, SensorEntity): return None - @property - def name(self) -> str: - """Return the name of the Canary sensor.""" - return self._name - - @property - def state(self) -> float | None: - """Return the state of the sensor.""" - return self.reading - - @property - def unique_id(self) -> str: - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type[0]}" - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, str(self._device_id))}, - "name": self._device_name, - "model": self._device_type_name, - "manufacturer": MANUFACTURER, - } - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - return self._sensor_type[1] - - @property - def device_class(self) -> str | None: - """Device class for the sensor.""" - return self._sensor_type[3] - - @property - def icon(self) -> str | None: - """Icon for the sensor.""" - return self._sensor_type[2] - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the state attributes.""" diff --git a/homeassistant/components/canary/translations/hu.json b/homeassistant/components/canary/translations/hu.json index 85dd503a175..77c72901742 100644 --- a/homeassistant/components/canary/translations/hu.json +++ b/homeassistant/components/canary/translations/hu.json @@ -22,6 +22,7 @@ "step": { "init": { "data": { + "ffmpeg_arguments": "A kamer\u00e1khoz az ffmpeg-nek \u00e1tadott argumentumok", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (opcion\u00e1lis)" } } diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 71caa6490d8..6d021d020c4 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -43,7 +43,7 @@ class ChromecastInfo: ) @property - def manufacturer(self) -> str: + def manufacturer(self) -> str | None: """Return the manufacturer.""" if self._manufacturer: return self._manufacturer diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index bb0354bb68e..fb2d790d03d 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -82,4 +82,5 @@ async def async_remove_user( if user_id is not None: user = await hass.auth.async_get_user(user_id) - await hass.auth.async_remove_user(user) + if user: + await hass.auth.async_remove_user(user) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 07e97dd1a7e..74c90f43372 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta import functools as ft import json import logging @@ -160,6 +160,9 @@ class CastDevice(MediaPlayerEntity): "elected leader" itself. """ + _attr_should_poll = False + _attr_media_image_remotely_accessible = True + def __init__(self, cast_info: ChromecastInfo) -> None: """Initialize the cast device.""" @@ -169,15 +172,24 @@ class CastDevice(MediaPlayerEntity): self.cast_status = None self.media_status = None self.media_status_received = None - self.mz_media_status = {} - self.mz_media_status_received = {} + self.mz_media_status: dict[str, pychromecast.controllers.media.MediaStatus] = {} + self.mz_media_status_received: dict[str, datetime] = {} self.mz_mgr = None - self._available = False + self._attr_available = False self._status_listener: CastStatusListener | None = None self._hass_cast_controller: HomeAssistantController | None = None self._add_remove_handler = None self._cast_view_remove_handler = None + self._attr_unique_id = cast_info.uuid + self._attr_name = cast_info.friendly_name + if cast_info.model_name != "Google Cast Group": + self._attr_device_info = { + "name": str(cast_info.friendly_name), + "identifiers": {(CAST_DOMAIN, str(cast_info.uuid).replace("-", ""))}, + "model": cast_info.model_name, + "manufacturer": str(cast_info.manufacturer), + } async def async_added_to_hass(self): """Create chromecast object when added to hass.""" @@ -239,7 +251,7 @@ class CastDevice(MediaPlayerEntity): self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) - self._available = False + self._attr_available = False self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status self._chromecast.start() @@ -255,7 +267,7 @@ class CastDevice(MediaPlayerEntity): self.entity_id, self._cast_info.friendly_name, ) - self._available = False + self._attr_available = False self.async_write_ha_state() await self.hass.async_add_executor_job(self._chromecast.disconnect) @@ -282,6 +294,10 @@ class CastDevice(MediaPlayerEntity): def new_cast_status(self, cast_status): """Handle updates of the cast status.""" self.cast_status = cast_status + self._attr_volume_level = cast_status.volume_level if cast_status else None + self._attr_is_volume_muted = ( + cast_status.volume_muted if self.cast_status else None + ) self.schedule_update_ha_state() def new_media_status(self, media_status): @@ -334,13 +350,13 @@ class CastDevice(MediaPlayerEntity): connection_status.status, ) if connection_status.status == CONNECTION_STATUS_DISCONNECTED: - self._available = False + self._attr_available = False self._invalidate() self.schedule_update_ha_state() return new_available = connection_status.status == CONNECTION_STATUS_CONNECTED - if new_available != self._available: + if new_available != self.available: # Connection status callbacks happen often when disconnected. # Only update state when availability changed to put less pressure # on state machine. @@ -350,7 +366,7 @@ class CastDevice(MediaPlayerEntity): self._cast_info.friendly_name, connection_status.status, ) - self._available = new_available + self._attr_available = new_available self.schedule_update_ha_state() def multizone_new_media_status(self, group_uuid, media_status): @@ -527,32 +543,6 @@ class CastDevice(MediaPlayerEntity): media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {}) ) - # ========== Properties ========== - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._cast_info.friendly_name - - @property - def device_info(self): - """Return information about the device.""" - cast_info = self._cast_info - - if cast_info.model_name == "Google Cast Group": - return None - - return { - "name": cast_info.friendly_name, - "identifiers": {(CAST_DOMAIN, cast_info.uuid.replace("-", ""))}, - "model": cast_info.model_name, - "manufacturer": cast_info.manufacturer, - } - def _media_status(self): """ Return media status. @@ -589,21 +579,6 @@ class CastDevice(MediaPlayerEntity): return STATE_OFF return None - @property - def available(self): - """Return True if the cast device is connected.""" - return self._available - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self.cast_status.volume_level if self.cast_status else None - - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self.cast_status.volume_muted if self.cast_status else None - @property def media_content_id(self): """Content ID of current playing media.""" @@ -641,11 +616,6 @@ class CastDevice(MediaPlayerEntity): return images[0].url if images and images[0].url else None - @property - def media_image_remotely_accessible(self) -> bool: - """If the image url is remotely accessible.""" - return True - @property def media_title(self): """Title of current playing media.""" @@ -748,11 +718,6 @@ class CastDevice(MediaPlayerEntity): media_status_recevied = self._media_status()[1] return media_status_recevied - @property - def unique_id(self) -> str | None: - """Return a unique ID.""" - return self._cast_info.uuid - async def _async_cast_discovered(self, discover: ChromecastInfo): """Handle discovery of new Chromecast.""" if self._cast_info.uuid != discover.uuid: @@ -774,7 +739,7 @@ class CastDevice(MediaPlayerEntity): url_path: str | None, ): """Handle a show view signal.""" - if entity_id != self.entity_id: + if entity_id != self.entity_id or self._chromecast is None: return if self._hass_cast_controller is None: diff --git a/homeassistant/components/cast/translations/de.json b/homeassistant/components/cast/translations/de.json index 4d029be9603..b337a8575e0 100644 --- a/homeassistant/components/cast/translations/de.json +++ b/homeassistant/components/cast/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "invalid_known_hosts": "Bekannte Hosts m\u00fcssen eine durch Kommata getrennte Liste von Hosts sein." @@ -15,7 +15,7 @@ "title": "Google Cast-Konfiguration" }, "confirm": { - "description": "M\u00f6chtest du Google Cast einrichten?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, @@ -29,7 +29,7 @@ "ignore_cec": "CEC ignorieren", "uuid": "Zul\u00e4ssige UUIDs" }, - "description": "Erlaubte UUIDs - Eine kommagetrennte Liste von UUIDs von Cast-Ger\u00e4ten, die dem Home Assistant hinzugef\u00fcgt werden sollen. Nur verwenden, wenn Sie nicht alle verf\u00fcgbaren Cast-Ger\u00e4te hinzuf\u00fcgen m\u00f6chten.\nCEC ignorieren - Eine kommagetrennte Liste von Chromecasts, die CEC-Daten zur Bestimmung des aktiven Eingangs ignorieren sollen. Dies wird an pychromecast.IGNORE_CEC \u00fcbergeben.", + "description": "Erlaubte UUIDs - Eine kommagetrennte Liste von UUIDs von Cast-Ger\u00e4ten, die dem Home Assistant hinzugef\u00fcgt werden sollen. Nur verwenden, wenn du nicht alle verf\u00fcgbaren Cast-Ger\u00e4te hinzuf\u00fcgen m\u00f6chtest.\nCEC ignorieren - Eine kommagetrennte Liste von Chromecasts, die CEC-Daten zur Bestimmung des aktiven Eingangs ignorieren sollen. Dies wird an pychromecast.IGNORE_CEC \u00fcbergeben.", "title": "Erweiterte Google Cast-Konfiguration" }, "basic_options": { diff --git a/homeassistant/components/cast/translations/he.json b/homeassistant/components/cast/translations/he.json index 1d3af8b2718..d50e5b20684 100644 --- a/homeassistant/components/cast/translations/he.json +++ b/homeassistant/components/cast/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4." + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "error": { "invalid_known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd." @@ -11,10 +11,11 @@ "data": { "known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" }, - "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc." + "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc.", + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea Google Cast" }, "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Google Cast?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } }, @@ -23,11 +24,15 @@ "invalid_known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd \u05d7\u05d9\u05d9\u05d1\u05d9\u05dd \u05dc\u05d4\u05d9\u05d5\u05ea \u05e8\u05e9\u05d9\u05de\u05ea \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d4\u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd." }, "step": { + "advanced_options": { + "title": "\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05ea\u05e7\u05d3\u05de\u05ea \u05e9\u05dc Google Cast" + }, "basic_options": { "data": { "known_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd" }, - "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc." + "description": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05d9\u05d3\u05d5\u05e2\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc \u05e9\u05de\u05d5\u05ea \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d9\u05e6\u05d5\u05e7\u05d9\u05dd, \u05d4\u05e9\u05ea\u05de\u05e9 \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 mDNS \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc.", + "title": "\u05ea\u05e6\u05d5\u05e8\u05ea Google Cast" } } } diff --git a/homeassistant/components/cast/translations/hu.json b/homeassistant/components/cast/translations/hu.json index 3b5840b1c14..0f64f8de6fe 100644 --- a/homeassistant/components/cast/translations/hu.json +++ b/homeassistant/components/cast/translations/hu.json @@ -22,6 +22,23 @@ "options": { "error": { "invalid_known_hosts": "Az ismert hosztoknak vessz\u0151vel elv\u00e1lasztott hosztok list\u00e1j\u00e1nak kell lennie." + }, + "step": { + "advanced_options": { + "data": { + "ignore_cec": "A CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa", + "uuid": "Enged\u00e9lyezett UUID-k" + }, + "description": "Enged\u00e9lyezett UUID - vessz\u0151vel elv\u00e1lasztott lista a Cast-eszk\u00f6z\u00f6k UUID-j\u00e9b\u0151l, amelyeket hozz\u00e1 lehet adni a Home Assistanthoz. Csak akkor haszn\u00e1lja, ha nem akarja hozz\u00e1adni az \u00f6sszes rendelkez\u00e9sre \u00e1ll\u00f3 cast eszk\u00f6zt.\n CEC figyelmen k\u00edv\u00fcl hagy\u00e1sa - vessz\u0151vel elv\u00e1lasztott Chromecast-lista, amelynek figyelmen k\u00edv\u00fcl kell hagynia a CEC-adatokat az akt\u00edv bemenet meghat\u00e1roz\u00e1s\u00e1hoz. Ezt tov\u00e1bb\u00edtjuk a pychromecast.IGNORE_CEC c\u00edmre.", + "title": "Speci\u00e1lis Google Cast-konfigur\u00e1ci\u00f3" + }, + "basic_options": { + "data": { + "known_hosts": "Ismert gazd\u00e1k" + }, + "description": "Ismert gazd\u00e1k - vessz\u0151vel elv\u00e1lasztott lista az eszk\u00f6z\u00f6k hosztneveir\u0151l vagy IP-c\u00edmeir\u0151l, akkor haszn\u00e1lja, ha az mDNS felder\u00edt\u00e9s nem m\u0171k\u00f6dik.", + "title": "Google Cast konfigur\u00e1ci\u00f3" + } } } } \ No newline at end of file diff --git a/homeassistant/components/cast/translations/id.json b/homeassistant/components/cast/translations/id.json index bd7e0c936b1..b2c8d515548 100644 --- a/homeassistant/components/cast/translations/id.json +++ b/homeassistant/components/cast/translations/id.json @@ -22,6 +22,17 @@ "options": { "error": { "invalid_known_hosts": "Host yang diketahui harus berupa daftar host yang dipisahkan koma." + }, + "step": { + "advanced_options": { + "title": "Konfigurasi Google Cast tingkat lanjut" + }, + "basic_options": { + "data": { + "known_hosts": "Host yang dikenal" + }, + "title": "Konfigurasi Google Cast" + } } } } \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index a05acdb5d77..787465bb6f3 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -62,10 +62,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class CertExpiryEntity(CoordinatorEntity): """Defines a base Cert Expiry entity.""" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return "mdi:certificate" + _attr_icon = "mdi:certificate" @property def extra_state_attributes(self): @@ -79,15 +76,13 @@ class CertExpiryEntity(CoordinatorEntity): class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): """Implementation of the Cert Expiry timestamp sensor.""" - @property - def device_class(self): - """Return the device class of the sensor.""" - return DEVICE_CLASS_TIMESTAMP + _attr_device_class = DEVICE_CLASS_TIMESTAMP - @property - def name(self): - """Return the name of the sensor.""" - return f"Cert Expiry Timestamp ({self.coordinator.name})" + def __init__(self, coordinator) -> None: + """Initialize a Cert Expiry timestamp sensor.""" + super().__init__(coordinator) + self._attr_name = f"Cert Expiry Timestamp ({coordinator.name})" + self._attr_unique_id = f"{coordinator.host}:{coordinator.port}-timestamp" @property def state(self): @@ -95,8 +90,3 @@ class SSLCertificateTimestamp(CertExpiryEntity, SensorEntity): if self.coordinator.data: return self.coordinator.data.isoformat() return None - - @property - def unique_id(self): - """Return a unique id for the sensor.""" - return f"{self.coordinator.host}:{self.coordinator.port}-timestamp" diff --git a/homeassistant/components/cert_expiry/translations/de.json b/homeassistant/components/cert_expiry/translations/de.json index 2c01c9f71a6..640e715b359 100644 --- a/homeassistant/components/cert_expiry/translations/de.json +++ b/homeassistant/components/cert_expiry/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "import_failed": "Import aus Konfiguration fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/cert_expiry/translations/he.json b/homeassistant/components/cert_expiry/translations/he.json index 9b55d58684e..1e14311ef67 100644 --- a/homeassistant/components/cert_expiry/translations/he.json +++ b/homeassistant/components/cert_expiry/translations/he.json @@ -11,7 +11,8 @@ "step": { "user": { "data": { - "host": "\u05de\u05d0\u05e8\u05d7" + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" } } } diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index b9eadec7d18..b30e9dae1f3 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -118,7 +118,7 @@ class CiscoDeviceScanner(DeviceScanner): router_hostname = initial_line[len(initial_line) - 1] router_hostname += "#" # Set the discovered hostname as prompt - regex_expression = ("(?i)^%s" % router_hostname).encode() + regex_expression = f"(?i)^{router_hostname}".encode() cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE) # Allow full arp table to print at once cisco_ssh.sendline("terminal length 0") diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index bc323a51151..7d54d259051 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -265,50 +265,32 @@ class CityBikesNetwork: class CityBikesStation(SensorEntity): """CityBikes API Sensor.""" + _attr_unit_of_measurement = "bikes" + _attr_icon = "mdi:bike" + def __init__(self, network, station_id, entity_id): """Initialize the sensor.""" self._network = network self._station_id = station_id - self._station_data = {} self.entity_id = entity_id - @property - def state(self): - """Return the state of the sensor.""" - return self._station_data.get(ATTR_FREE_BIKES) - - @property - def name(self): - """Return the name of the sensor.""" - return self._station_data.get(ATTR_NAME) - async def async_update(self): """Update station state.""" for station in self._network.stations: if station[ATTR_ID] == self._station_id: - self._station_data = station + station_data = station break - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if self._station_data: - return { + self._attr_name = station_data.get(ATTR_NAME) + self._attr_state = station_data.get(ATTR_FREE_BIKES) + self._attr_extra_state_attributes = ( + { ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION, - ATTR_UID: self._station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), - ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], - ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], - ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], - ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], + ATTR_UID: station_data.get(ATTR_EXTRA, {}).get(ATTR_UID), + ATTR_LATITUDE: station_data[ATTR_LATITUDE], + ATTR_LONGITUDE: station_data[ATTR_LONGITUDE], + ATTR_EMPTY_SLOTS: station_data[ATTR_EMPTY_SLOTS], + ATTR_TIMESTAMP: station_data[ATTR_TIMESTAMP], } - return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return "bikes" - - @property - def icon(self): - """Return the icon.""" - return "mdi:bike" + if station_data + else {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} + ) diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 44ba5c3d600..2d7099e1f54 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -67,18 +67,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ClementineDevice(MediaPlayerEntity): """Representation of Clementine Player.""" + _attr_media_content_type = MEDIA_TYPE_MUSIC + _attr_supported_features = SUPPORT_CLEMENTINE + def __init__(self, client, name): """Initialize the Clementine device.""" self._client = client - self._name = name - self._muted = False - self._volume = 0.0 - self._track_id = 0 - self._last_track_id = 0 - self._track_name = "" - self._track_artist = "" - self._track_album_name = "" - self._state = None + self._attr_name = name def update(self): """Retrieve the latest data from the Clementine Player.""" @@ -86,59 +81,37 @@ class ClementineDevice(MediaPlayerEntity): client = self._client if client.state == "Playing": - self._state = STATE_PLAYING + self._attr_state = STATE_PLAYING elif client.state == "Paused": - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED elif client.state == "Disconnected": - self._state = STATE_OFF + self._attr_state = STATE_OFF else: - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED if client.last_update and (time.time() - client.last_update > 40): - self._state = STATE_OFF + self._attr_state = STATE_OFF - self._volume = float(client.volume) if client.volume else 0.0 + volume = float(client.volume) if client.volume else 0.0 + self._attr_volume_level = volume / 100.0 + if client.active_playlist_id in client.playlists: + self._attr_source = client.playlists[client.active_playlist_id]["name"] + else: + self._attr_source = "Unknown" + self._attr_source_list = [s["name"] for s in client.playlists.values()] if client.current_track: - self._track_id = client.current_track["track_id"] - self._track_name = client.current_track["title"] - self._track_artist = client.current_track["track_artist"] - self._track_album_name = client.current_track["track_album"] + self._attr_media_title = client.current_track["title"] + self._attr_media_artist = client.current_track["track_artist"] + self._attr_media_album_name = client.current_track["track_album"] + self._attr_media_image_hash = client.current_track["track_id"] + else: + self._attr_media_image_hash = None except Exception: - self._state = STATE_OFF + self._attr_state = STATE_OFF raise - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume / 100.0 - - @property - def source(self): - """Return current source name.""" - source_name = "Unknown" - client = self._client - if client.active_playlist_id in client.playlists: - source_name = client.playlists[client.active_playlist_id]["name"] - return source_name - - @property - def source_list(self): - """List of available input sources.""" - source_names = [s["name"] for s in self._client.playlists.values()] - return source_names - def select_source(self, source): """Select input source.""" client = self._client @@ -146,39 +119,6 @@ class ClementineDevice(MediaPlayerEntity): if len(sources) == 1: client.change_song(sources[0]["id"], 0) - @property - def media_content_type(self): - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def media_title(self): - """Title of current playing media.""" - return self._track_name - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self._track_artist - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._track_album_name - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_CLEMENTINE - - @property - def media_image_hash(self): - """Hash value for media image.""" - if self._client.current_track: - return self._client.current_track["track_id"] - - return None - async def async_get_media_image(self): """Fetch media image of current playing image.""" if self._client.current_track: @@ -207,19 +147,19 @@ class ClementineDevice(MediaPlayerEntity): def media_play_pause(self): """Simulate play pause media player.""" - if self._state == STATE_PLAYING: + if self.state == STATE_PLAYING: self.media_pause() else: self.media_play() def media_play(self): """Send play command.""" - self._state = STATE_PLAYING + self._attr_state = STATE_PLAYING self._client.play() def media_pause(self): """Send media pause command to media player.""" - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED self._client.pause() def media_next_track(self): diff --git a/homeassistant/components/climacell/__init__.py b/homeassistant/components/climacell/__init__.py index 09909ae4e3a..97090cef31d 100644 --- a/homeassistant/components/climacell/__init__.py +++ b/homeassistant/components/climacell/__init__.py @@ -35,7 +35,6 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( - ATTR_FIELD, ATTRIBUTION, CC_ATTR_CLOUD_COVER, CC_ATTR_CONDITION, @@ -223,10 +222,7 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): CC_V3_ATTR_WIND_GUST, CC_V3_ATTR_CLOUD_COVER, CC_V3_ATTR_PRECIPITATION_TYPE, - *[ - sensor_type[ATTR_FIELD] - for sensor_type in CC_V3_SENSOR_TYPES - ], + *(sensor_type.key for sensor_type in CC_V3_SENSOR_TYPES), ] ) data[FORECASTS][HOURLY] = await self._api.forecast_hourly( @@ -283,7 +279,7 @@ class ClimaCellDataUpdateCoordinator(DataUpdateCoordinator): CC_ATTR_WIND_GUST, CC_ATTR_CLOUD_COVER, CC_ATTR_PRECIPITATION_TYPE, - *[sensor_type[ATTR_FIELD] for sensor_type in CC_SENSOR_TYPES], + *(sensor_type.key for sensor_type in CC_SENSOR_TYPES), ], [ CC_ATTR_TEMPERATURE_LOW, diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 057cef5e993..9e80c769abf 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -1,4 +1,10 @@ """Constants for the ClimaCell integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum +from typing import Callable + from pyclimacell.const import ( DAILY, HOURLY, @@ -11,6 +17,7 @@ from pyclimacell.const import ( WeatherCode, ) +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -26,14 +33,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ) from homeassistant.const import ( - ATTR_NAME, CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_UNIT_OF_MEASUREMENT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, + DEVICE_CLASS_CO, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_KILOMETERS, @@ -70,13 +76,6 @@ MAX_FORECASTS = { NOWCAST: 30, } -# Sensor type keys -ATTR_FIELD = "field" -ATTR_METRIC_CONVERSION = "metric_conversion" -ATTR_VALUE_MAP = "value_map" -ATTR_IS_METRIC_CHECK = "is_metric_check" -ATTR_SCALE = "scale" - # Additional attributes ATTR_WIND_GUST = "wind_gust" ATTR_CLOUD_COVER = "cloud_cover" @@ -151,161 +150,195 @@ CC_ATTR_SOLAR_GHI = "solarGHI" CC_ATTR_CLOUD_BASE = "cloudBase" CC_ATTR_CLOUD_CEILING = "cloudCeiling" -CC_SENSOR_TYPES = [ - { - ATTR_FIELD: CC_ATTR_FEELS_LIKE, - ATTR_NAME: "Feels Like", - CONF_UNIT_SYSTEM_IMPERIAL: TEMP_FAHRENHEIT, - CONF_UNIT_SYSTEM_METRIC: TEMP_CELSIUS, - ATTR_METRIC_CONVERSION: lambda val: temp_convert( - val, TEMP_FAHRENHEIT, TEMP_CELSIUS - ), - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_DEW_POINT, - ATTR_NAME: "Dew Point", - CONF_UNIT_SYSTEM_IMPERIAL: TEMP_FAHRENHEIT, - CONF_UNIT_SYSTEM_METRIC: TEMP_CELSIUS, - ATTR_METRIC_CONVERSION: lambda val: temp_convert( - val, TEMP_FAHRENHEIT, TEMP_CELSIUS - ), - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_PRESSURE_SURFACE_LEVEL, - ATTR_NAME: "Pressure (Surface Level)", - CONF_UNIT_SYSTEM_IMPERIAL: PRESSURE_INHG, - CONF_UNIT_SYSTEM_METRIC: PRESSURE_HPA, - ATTR_METRIC_CONVERSION: lambda val: pressure_convert( + +@dataclass +class ClimaCellSensorEntityDescription(SensorEntityDescription): + """Describes a ClimaCell sensor entity.""" + + unit_imperial: str | None = None + unit_metric: str | None = None + metric_conversion: Callable[[float], float] | float = 1.0 + is_metric_check: bool | None = None + device_class: str | None = None + value_map: IntEnum | None = None + + def __post_init__(self) -> None: + """Post initialization.""" + units = (self.unit_imperial, self.unit_metric) + if any(u is not None for u in units) and any(u is None for u in units): + raise RuntimeError( + "`unit_imperial` and `unit_metric` both need to be None or both need " + "to be defined." + ) + + +CC_SENSOR_TYPES = ( + ClimaCellSensorEntityDescription( + key=CC_ATTR_FEELS_LIKE, + name="Feels Like", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_DEW_POINT, + name="Dew Point", + unit_imperial=TEMP_FAHRENHEIT, + unit_metric=TEMP_CELSIUS, + metric_conversion=lambda val: temp_convert(val, TEMP_FAHRENHEIT, TEMP_CELSIUS), + is_metric_check=True, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PRESSURE_SURFACE_LEVEL, + name="Pressure (Surface Level)", + unit_imperial=PRESSURE_INHG, + unit_metric=PRESSURE_HPA, + metric_conversion=lambda val: pressure_convert( val, PRESSURE_INHG, PRESSURE_HPA ), - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_SOLAR_GHI, - ATTR_NAME: "Global Horizontal Irradiance", - CONF_UNIT_SYSTEM_IMPERIAL: IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, - CONF_UNIT_SYSTEM_METRIC: IRRADIATION_WATTS_PER_SQUARE_METER, - ATTR_METRIC_CONVERSION: 3.15459, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_CLOUD_BASE, - ATTR_NAME: "Cloud Base", - CONF_UNIT_SYSTEM_IMPERIAL: LENGTH_MILES, - CONF_UNIT_SYSTEM_METRIC: LENGTH_KILOMETERS, - ATTR_METRIC_CONVERSION: lambda val: distance_convert( + is_metric_check=True, + device_class=DEVICE_CLASS_PRESSURE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_SOLAR_GHI, + name="Global Horizontal Irradiance", + unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, + unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, + metric_conversion=3.15459, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_BASE, + name="Cloud Base", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( val, LENGTH_MILES, LENGTH_KILOMETERS ), - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_CLOUD_CEILING, - ATTR_NAME: "Cloud Ceiling", - CONF_UNIT_SYSTEM_IMPERIAL: LENGTH_MILES, - CONF_UNIT_SYSTEM_METRIC: LENGTH_KILOMETERS, - ATTR_METRIC_CONVERSION: lambda val: distance_convert( + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_CEILING, + name="Cloud Ceiling", + unit_imperial=LENGTH_MILES, + unit_metric=LENGTH_KILOMETERS, + metric_conversion=lambda val: distance_convert( val, LENGTH_MILES, LENGTH_KILOMETERS ), - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_CLOUD_COVER, - ATTR_NAME: "Cloud Cover", - CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_FIELD: CC_ATTR_WIND_GUST, - ATTR_NAME: "Wind Gust", - CONF_UNIT_SYSTEM_IMPERIAL: SPEED_MILES_PER_HOUR, - CONF_UNIT_SYSTEM_METRIC: SPEED_METERS_PER_SECOND, - ATTR_METRIC_CONVERSION: lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_METERS - ) + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CLOUD_COVER, + name="Cloud Cover", + unit_imperial=PERCENTAGE, + unit_metric=PERCENTAGE, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_WIND_GUST, + name="Wind Gust", + unit_imperial=SPEED_MILES_PER_HOUR, + unit_metric=SPEED_METERS_PER_SECOND, + metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) / 3600, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_PRECIPITATION_TYPE, - ATTR_NAME: "Precipitation Type", - ATTR_VALUE_MAP: PrecipitationType, - }, - { - ATTR_FIELD: CC_ATTR_OZONE, - ATTR_NAME: "Ozone", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_25, - ATTR_NAME: "Particulate Matter < 2.5 μm", - CONF_UNIT_SYSTEM_IMPERIAL: CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_PARTICULATE_MATTER_10, - ATTR_NAME: "Particulate Matter < 10 μm", - CONF_UNIT_SYSTEM_IMPERIAL: CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: True, - }, - { - ATTR_FIELD: CC_ATTR_NITROGEN_DIOXIDE, - ATTR_NAME: "Nitrogen Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_ATTR_CARBON_MONOXIDE, - ATTR_NAME: "Carbon Monoxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_ATTR_SULFUR_DIOXIDE, - ATTR_NAME: "Sulfur Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - {ATTR_FIELD: CC_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, - { - ATTR_FIELD: CC_ATTR_EPA_PRIMARY_POLLUTANT, - ATTR_NAME: "US EPA Primary Pollutant", - ATTR_VALUE_MAP: PrimaryPollutantType, - }, - { - ATTR_FIELD: CC_ATTR_EPA_HEALTH_CONCERN, - ATTR_NAME: "US EPA Health Concern", - ATTR_VALUE_MAP: HealthConcernType, - }, - {ATTR_FIELD: CC_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, - { - ATTR_FIELD: CC_ATTR_CHINA_PRIMARY_POLLUTANT, - ATTR_NAME: "China MEP Primary Pollutant", - ATTR_VALUE_MAP: PrimaryPollutantType, - }, - { - ATTR_FIELD: CC_ATTR_CHINA_HEALTH_CONCERN, - ATTR_NAME: "China MEP Health Concern", - ATTR_VALUE_MAP: HealthConcernType, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_TREE, - ATTR_NAME: "Tree Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_WEED, - ATTR_NAME: "Weed Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - { - ATTR_FIELD: CC_ATTR_POLLEN_GRASS, - ATTR_NAME: "Grass Pollen Index", - ATTR_VALUE_MAP: PollenIndex, - }, - {ATTR_FIELD: CC_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, -] + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PRECIPITATION_TYPE, + name="Precipitation Type", + value_map=PrecipitationType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=True, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + value_map=PrimaryPollutantType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + value_map=HealthConcernType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + value_map=PrimaryPollutantType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + value_map=HealthConcernType, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=PollenIndex, + ), + ClimaCellSensorEntityDescription( + CC_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) # V3 constants CONDITIONS_V3 = { @@ -369,72 +402,89 @@ CC_V3_ATTR_POLLEN_WEED = "pollen_weed" CC_V3_ATTR_POLLEN_GRASS = "pollen_grass" CC_V3_ATTR_FIRE_INDEX = "fire_index" -CC_V3_SENSOR_TYPES = [ - { - ATTR_FIELD: CC_V3_ATTR_OZONE, - ATTR_NAME: "Ozone", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_25, - ATTR_NAME: "Particulate Matter < 2.5 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: False, - }, - { - ATTR_FIELD: CC_V3_ATTR_PARTICULATE_MATTER_10, - ATTR_NAME: "Particulate Matter < 10 μm", - CONF_UNIT_SYSTEM_IMPERIAL: "μg/ft³", - CONF_UNIT_SYSTEM_METRIC: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_METRIC_CONVERSION: 3.2808399 ** 3, - ATTR_IS_METRIC_CHECK: False, - }, - { - ATTR_FIELD: CC_V3_ATTR_NITROGEN_DIOXIDE, - ATTR_NAME: "Nitrogen Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - { - ATTR_FIELD: CC_V3_ATTR_CARBON_MONOXIDE, - ATTR_NAME: "Carbon Monoxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, - }, - { - ATTR_FIELD: CC_V3_ATTR_SULFUR_DIOXIDE, - ATTR_NAME: "Sulfur Dioxide", - CONF_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, - }, - {ATTR_FIELD: CC_V3_ATTR_EPA_AQI, ATTR_NAME: "US EPA Air Quality Index"}, - { - ATTR_FIELD: CC_V3_ATTR_EPA_PRIMARY_POLLUTANT, - ATTR_NAME: "US EPA Primary Pollutant", - }, - {ATTR_FIELD: CC_V3_ATTR_EPA_HEALTH_CONCERN, ATTR_NAME: "US EPA Health Concern"}, - {ATTR_FIELD: CC_V3_ATTR_CHINA_AQI, ATTR_NAME: "China MEP Air Quality Index"}, - { - ATTR_FIELD: CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT, - ATTR_NAME: "China MEP Primary Pollutant", - }, - { - ATTR_FIELD: CC_V3_ATTR_CHINA_HEALTH_CONCERN, - ATTR_NAME: "China MEP Health Concern", - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_TREE, - ATTR_NAME: "Tree Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_WEED, - ATTR_NAME: "Weed Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - { - ATTR_FIELD: CC_V3_ATTR_POLLEN_GRASS, - ATTR_NAME: "Grass Pollen Index", - ATTR_VALUE_MAP: V3PollenIndex, - }, - {ATTR_FIELD: CC_V3_ATTR_FIRE_INDEX, ATTR_NAME: "Fire Index"}, -] +CC_V3_SENSOR_TYPES = ( + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_OZONE, + name="Ozone", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_PARTICULATE_MATTER_25, + name="Particulate Matter < 2.5 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=False, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_PARTICULATE_MATTER_10, + name="Particulate Matter < 10 μm", + unit_imperial=CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT, + unit_metric=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + metric_conversion=3.2808399 ** 3, + is_metric_check=False, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_NITROGEN_DIOXIDE, + name="Nitrogen Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CARBON_MONOXIDE, + name="Carbon Monoxide", + unit_imperial=CONCENTRATION_PARTS_PER_MILLION, + unit_metric=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_SULFUR_DIOXIDE, + name="Sulfur Dioxide", + unit_imperial=CONCENTRATION_PARTS_PER_BILLION, + unit_metric=CONCENTRATION_PARTS_PER_BILLION, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_AQI, + name="US EPA Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_PRIMARY_POLLUTANT, + name="US EPA Primary Pollutant", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_EPA_HEALTH_CONCERN, + name="US EPA Health Concern", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_AQI, + name="China MEP Air Quality Index", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_PRIMARY_POLLUTANT, + name="China MEP Primary Pollutant", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_CHINA_HEALTH_CONCERN, + name="China MEP Health Concern", + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_TREE, + name="Tree Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_WEED, + name="Weed Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_POLLEN_GRASS, + name="Grass Pollen Index", + value_map=V3PollenIndex, + ), + ClimaCellSensorEntityDescription( + key=CC_V3_ATTR_FIRE_INDEX, + name="Fire Index", + ), +) diff --git a/homeassistant/components/climacell/sensor.py b/homeassistant/components/climacell/sensor.py index 2c620cc65a1..3f96dd9e02c 100644 --- a/homeassistant/components/climacell/sensor.py +++ b/homeassistant/components/climacell/sensor.py @@ -2,37 +2,23 @@ from __future__ import annotations from abc import abstractmethod -from collections.abc import Mapping import logging -from typing import Any from pyclimacell.const import CURRENT from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_NAME, - CONF_API_VERSION, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_VERSION, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity from .const import ( - ATTR_FIELD, - ATTR_IS_METRIC_CHECK, - ATTR_METRIC_CONVERSION, - ATTR_SCALE, - ATTR_VALUE_MAP, CC_SENSOR_TYPES, CC_V3_SENSOR_TYPES, DOMAIN, + ClimaCellSensorEntityDescription, ) _LOGGER = logging.getLogger(__name__) @@ -54,8 +40,8 @@ async def async_setup_entry( api_class = ClimaCellSensorEntity sensor_types = CC_SENSOR_TYPES entities = [ - api_class(config_entry, coordinator, api_version, sensor_type) - for sensor_type in sensor_types + api_class(hass, config_entry, coordinator, api_version, description) + for description in sensor_types ] async_add_entities(entities) @@ -63,54 +49,30 @@ async def async_setup_entry( class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): """Base ClimaCell sensor entity.""" + entity_description: ClimaCellSensorEntityDescription + def __init__( self, + hass: HomeAssistant, config_entry: ConfigEntry, coordinator: ClimaCellDataUpdateCoordinator, api_version: int, - sensor_type: dict[str, str | float], + description: ClimaCellSensorEntityDescription, ) -> None: """Initialize ClimaCell Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) - self.sensor_type = sensor_type - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"{self._config_entry.data[CONF_NAME]} - {self.sensor_type[ATTR_NAME]}" - - @property - def unique_id(self) -> str: - """Return the unique id of the entity.""" - return f"{self._config_entry.unique_id}_{slugify(self.sensor_type[ATTR_NAME])}" - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return entity specific state attributes.""" - return {ATTR_ATTRIBUTION: self.attribution} - - @property - def unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - if CONF_UNIT_OF_MEASUREMENT in self.sensor_type: - return self.sensor_type[CONF_UNIT_OF_MEASUREMENT] - - if ( - CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type - and CONF_UNIT_SYSTEM_METRIC in self.sensor_type - ): - return ( - self.sensor_type[CONF_UNIT_SYSTEM_METRIC] - if self.hass.config.units.is_metric - else self.sensor_type[CONF_UNIT_SYSTEM_IMPERIAL] - ) - - return None + self.entity_description = description + self._attr_entity_registry_enabled_default = False + self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" + self._attr_unique_id = ( + f"{self._config_entry.unique_id}_{slugify(description.name)}" + ) + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: self.attribution} + self._attr_unit_of_measurement = ( + description.unit_metric + if hass.config.units.is_metric + else description.unit_imperial + ) @property @abstractmethod @@ -121,27 +83,23 @@ class BaseClimaCellSensorEntity(ClimaCellEntity, SensorEntity): def state(self) -> str | int | float | None: """Return the state.""" state = self._state - if state and ATTR_SCALE in self.sensor_type: - state *= self.sensor_type[ATTR_SCALE] - if ( state is not None - and CONF_UNIT_SYSTEM_IMPERIAL in self.sensor_type - and CONF_UNIT_SYSTEM_METRIC in self.sensor_type - and ATTR_METRIC_CONVERSION in self.sensor_type - and ATTR_IS_METRIC_CHECK in self.sensor_type + and self.entity_description.unit_imperial is not None + and self.entity_description.metric_conversion != 1.0 + and self.entity_description.is_metric_check is not None and self.hass.config.units.is_metric - == self.sensor_type[ATTR_IS_METRIC_CHECK] + == self.entity_description.is_metric_check ): - conversion = self.sensor_type[ATTR_METRIC_CONVERSION] + conversion = self.entity_description.metric_conversion # When conversion is a callable, we assume it's a single input function if callable(conversion): return round(conversion(state), 4) return round(state * conversion, 4) - if ATTR_VALUE_MAP in self.sensor_type and state is not None: - return self.sensor_type[ATTR_VALUE_MAP](state).name.lower() + if self.entity_description.value_map is not None and state is not None: + return self.entity_description.value_map(state).name.lower() return state @@ -152,7 +110,7 @@ class ClimaCellSensorEntity(BaseClimaCellSensorEntity): @property def _state(self) -> str | int | float | None: """Return the raw state.""" - return self._get_current_property(self.sensor_type[ATTR_FIELD]) + return self._get_current_property(self.entity_description.key) class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): @@ -162,5 +120,5 @@ class ClimaCellV3SensorEntity(BaseClimaCellSensorEntity): def _state(self) -> str | int | float | None: """Return the raw state.""" return self._get_cc_value( - self.coordinator.data[CURRENT], self.sensor_type[ATTR_FIELD] + self.coordinator.data[CURRENT], self.entity_description.key ) diff --git a/homeassistant/components/climacell/strings.json b/homeassistant/components/climacell/strings.json index f4347d254b7..44021f4b6d0 100644 --- a/homeassistant/components/climacell/strings.json +++ b/homeassistant/components/climacell/strings.json @@ -1,5 +1,4 @@ { - "title": "ClimaCell", "config": { "step": { "user": { diff --git a/homeassistant/components/climacell/translations/de.json b/homeassistant/components/climacell/translations/de.json index 8e269db785b..123a1257d99 100644 --- a/homeassistant/components/climacell/translations/de.json +++ b/homeassistant/components/climacell/translations/de.json @@ -25,7 +25,7 @@ "data": { "timestep": "Minuten zwischen den Kurzvorhersagen" }, - "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", + "description": "Wenn du die Vorhersage-Entitit\u00e4t \"Kurzvorhersage\" aktivierst, kannst du die Anzahl der Minuten zwischen den einzelnen Vorhersagen konfigurieren. Die Anzahl der bereitgestellten Vorhersagen h\u00e4ngt von der Anzahl der zwischen den Vorhersagen gew\u00e4hlten Minuten ab.", "title": "Aktualisiere ClimaCell-Optionen" } } diff --git a/homeassistant/components/climacell/translations/hu.json b/homeassistant/components/climacell/translations/hu.json index 6d97a51b530..909a5cdf1b5 100644 --- a/homeassistant/components/climacell/translations/hu.json +++ b/homeassistant/components/climacell/translations/hu.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", + "rate_limited": "Jelenleg korl\u00e1tozott sebess\u00e9g\u0171, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg k\u00e9s\u0151bb \u00fajra.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { @@ -21,6 +22,10 @@ "options": { "step": { "init": { + "data": { + "timestep": "Min. A NowCast el\u0151rejelz\u00e9sek k\u00f6z\u00f6tt" + }, + "description": "Ha a `nowcast` el\u0151rejelz\u00e9si entit\u00e1s enged\u00e9lyez\u00e9s\u00e9t v\u00e1lasztja, be\u00e1ll\u00edthatja az egyes el\u0151rejelz\u00e9sek k\u00f6z\u00f6tti percek sz\u00e1m\u00e1t. A megadott el\u0151rejelz\u00e9sek sz\u00e1ma az el\u0151rejelz\u00e9sek k\u00f6z\u00f6tt kiv\u00e1lasztott percek sz\u00e1m\u00e1t\u00f3l f\u00fcgg.", "title": "Friss\u00edtse a ClimaCell be\u00e1ll\u00edt\u00e1sokat" } } diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index be03b53ef72..865c2baa330 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -109,7 +109,7 @@ async def async_setup_entry( api_class = ClimaCellV3WeatherEntity if api_version == 3 else ClimaCellWeatherEntity entities = [ api_class(config_entry, coordinator, api_version, forecast_type) - for forecast_type in [DAILY, HOURLY, NOWCAST] + for forecast_type in (DAILY, HOURLY, NOWCAST) ] async_add_entities(entities) @@ -127,24 +127,11 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Initialize ClimaCell Weather Entity.""" super().__init__(config_entry, coordinator, api_version) self.forecast_type = forecast_type - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - if self.forecast_type == DEFAULT_FORECAST_TYPE: - return True - - return False - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"{self._config_entry.data[CONF_NAME]} - {self.forecast_type.title()}" - - @property - def unique_id(self) -> str: - """Return the unique id of the entity.""" - return f"{self._config_entry.unique_id}_{self.forecast_type}" + self._attr_entity_registry_enabled_default = ( + forecast_type == DEFAULT_FORECAST_TYPE + ) + self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_unique_id = f"{config_entry.unique_id}_{forecast_type}" @staticmethod @abstractmethod @@ -274,6 +261,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v4 API to retrieve weather data.""" + _attr_temperature_unit = TEMP_FAHRENHEIT + @staticmethod def _translate_condition( condition: int | None, sun_is_up: bool = True @@ -294,11 +283,6 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): """Return the platform temperature.""" return self._get_current_property(CC_ATTR_TEMPERATURE) - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - @property def _pressure(self): """Return the raw pressure.""" @@ -424,6 +408,8 @@ class ClimaCellWeatherEntity(BaseClimaCellWeatherEntity): class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v3 API to retrieve weather data.""" + _attr_temperature_unit = TEMP_FAHRENHEIT + @staticmethod def _translate_condition( condition: str | None, sun_is_up: bool = True @@ -444,11 +430,6 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): self.coordinator.data[CURRENT], CC_V3_ATTR_TEMPERATURE ) - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - @property def _pressure(self): """Return the raw pressure.""" diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index dbd74d1c5e8..6a46c1986b8 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with climate devices.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -19,17 +20,17 @@ from homeassistant.const import ( STATE_ON, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp -from homeassistant.helpers.typing import ConfigType, ServiceDataType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.temperature import convert as convert_temperature from .const import ( @@ -169,9 +170,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class ClimateEntityDescription(EntityDescription): + """A class that describes climate entities.""" + + class ClimateEntity(Entity): """Base class for climate entities.""" + entity_description: ClimateEntityDescription _attr_current_humidity: int | None = None _attr_current_temperature: float | None = None _attr_fan_mode: str | None @@ -248,7 +255,7 @@ class ClimateEntity(Entity): def state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" supported_features = self.supported_features - data = { + data: dict[str, str | float | None] = { ATTR_CURRENT_TEMPERATURE: show_temp( self.hass, self.current_temperature, @@ -498,7 +505,7 @@ class ClimateEntity(Entity): """Turn the entity on.""" if hasattr(self, "turn_on"): # pylint: disable=no-member - await self.hass.async_add_executor_job(self.turn_on) + await self.hass.async_add_executor_job(self.turn_on) # type: ignore[attr-defined] return # Fake turn on @@ -512,7 +519,7 @@ class ClimateEntity(Entity): """Turn the entity off.""" if hasattr(self, "turn_off"): # pylint: disable=no-member - await self.hass.async_add_executor_job(self.turn_off) + await self.hass.async_add_executor_job(self.turn_off) # type: ignore[attr-defined] return # Fake turn off @@ -554,23 +561,23 @@ class ClimateEntity(Entity): async def async_service_aux_heat( - entity: ClimateEntity, service: ServiceDataType + entity: ClimateEntity, service_call: ServiceCall ) -> None: """Handle aux heat service.""" - if service.data[ATTR_AUX_HEAT]: + if service_call.data[ATTR_AUX_HEAT]: await entity.async_turn_aux_heat_on() else: await entity.async_turn_aux_heat_off() async def async_service_temperature_set( - entity: ClimateEntity, service: ServiceDataType + entity: ClimateEntity, service_call: ServiceCall ) -> None: """Handle set temperature service.""" hass = entity.hass kwargs = {} - for value, temp in service.data.items(): + for value, temp in service_call.data.items(): if value in CONVERTIBLE_ATTRIBUTE: kwargs[value] = convert_temperature( temp, hass.config.units.temperature_unit, entity.temperature_unit diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 3fc6d94ba4f..97bb4515f14 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -85,7 +85,7 @@ def async_condition_from_config( def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" state = hass.states.get(config[ATTR_ENTITY_ID]) - return state and state.attributes.get(attribute) == config[attribute] + return state.attributes.get(attribute) == config[attribute] if state else False return test_is_state diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 767a38b2e57..f7e63f475ea 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -41,8 +41,8 @@ async def _async_reproduce_states( data = data or {} data["entity_id"] = state.entity_id for key in keys: - if key in state.attributes: - data[key] = state.attributes[key] + if (value := state.attributes.get(key)) is not None: + data[key] = value await hass.services.async_call( DOMAIN, service, data, blocking=True, context=context diff --git a/homeassistant/components/climate/translations/he.json b/homeassistant/components/climate/translations/he.json index fe5380a0528..abf976c2b5b 100644 --- a/homeassistant/components/climate/translations/he.json +++ b/homeassistant/components/climate/translations/he.json @@ -1,8 +1,23 @@ { + "device_automation": { + "action_type": { + "set_hvac_mode": "\u05e9\u05e0\u05d4 \u05de\u05e6\u05d1 HVAC \u05d1-{entity_name}", + "set_preset_mode": "\u05e9\u05e0\u05d4 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05e7\u05d1\u05d5\u05e2\u05d4 \u05de\u05e8\u05d0\u05e9 \u05d1-{entity_name}" + }, + "condition_type": { + "is_hvac_mode": "{entity_name} \u05de\u05d5\u05d2\u05d3\u05e8 \u05dc\u05de\u05e6\u05d1 HVAC \u05e1\u05e4\u05e6\u05d9\u05e4\u05d9", + "is_preset_mode": "{entity_name} \u05de\u05d5\u05d2\u05d3\u05e8 \u05dc\u05de\u05e6\u05d1 \u05e1\u05e4\u05e6\u05d9\u05e4\u05d9 \u05d4\u05de\u05d5\u05d2\u05d3\u05e8 \u05de\u05e8\u05d0\u05e9" + }, + "trigger_type": { + "current_humidity_changed": "\u05d4\u05dc\u05d7\u05d5\u05ea \u05d4\u05e0\u05de\u05d3\u05d3\u05ea {entity_name} \u05d4\u05e9\u05ea\u05e0\u05ea\u05d4", + "current_temperature_changed": "\u05d4\u05d8\u05de\u05e4\u05e8\u05d8\u05d5\u05e8\u05d4 \u05d4\u05e0\u05de\u05d3\u05d3\u05ea \u05e9\u05dc {entity_name} \u05d4\u05e9\u05ea\u05e0\u05ea\u05d4", + "hvac_mode_changed": "{entity_name} \u05de\u05de\u05e6\u05d1 HVAC \u05d4\u05e9\u05ea\u05e0\u05d4" + } + }, "state": { "_": { "auto": "\u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9", - "cool": "\u05e7\u05e8\u05d5\u05e8", + "cool": "\u05e7\u05d9\u05e8\u05d5\u05e8", "dry": "\u05d9\u05d1\u05e9", "fan_only": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8 \u05d1\u05dc\u05d1\u05d3", "heat": "\u05d7\u05d9\u05de\u05d5\u05dd", @@ -10,5 +25,5 @@ "off": "\u05db\u05d1\u05d5\u05d9" } }, - "title": "\u05d0\u05b7\u05e7\u05dc\u05b4\u05d9\u05dd" + "title": "\u05d0\u05e7\u05dc\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index df93ca6a6ab..5ad7ddcffed 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -143,6 +143,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" - return await account_link.async_fetch_access_token( + new_token = await account_link.async_fetch_access_token( self.hass.data[DOMAIN], self.service, token["refresh_token"] ) + return {**token, **new_token} diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 0e3b20fa011..f96bda4ce1b 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -24,41 +24,26 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class CloudRemoteBinary(BinarySensorEntity): """Representation of an Cloud Remote UI Connection binary sensor.""" + _attr_name = "Remote UI" + _attr_device_class = DEVICE_CLASS_CONNECTIVITY + _attr_should_poll = False + _attr_unique_id = "cloud-remote-ui-connectivity" + def __init__(self, cloud): """Initialize the binary sensor.""" self.cloud = cloud self._unsub_dispatcher = None - @property - def name(self) -> str: - """Return the name of the binary sensor, if any.""" - return "Remote UI" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return "cloud-remote-ui-connectivity" - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.cloud.remote.is_connected - @property - def device_class(self) -> str: - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_CONNECTIVITY - @property def available(self) -> bool: """Return True if entity is available.""" return self.cloud.remote.certificate is not None - @property - def should_poll(self) -> bool: - """Return True if entity has to be polled for state.""" - return False - async def async_added_to_hass(self): """Register update dispatcher.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c29e79f4e84..93c6fcd9086 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -148,7 +148,7 @@ class CloudClient(Interface): tasks.append(enable_google) if tasks: - await asyncio.gather(*[task(None) for task in tasks]) + await asyncio.gather(*(task(None) for task in tasks)) async def cleanups(self) -> None: """Cleanup some stuff after logout.""" diff --git a/homeassistant/components/cloudflare/translations/ca.json b/homeassistant/components/cloudflare/translations/ca.json index d0ffdcd5429..df26eaa73bc 100644 --- a/homeassistant/components/cloudflare/translations/ca.json +++ b/homeassistant/components/cloudflare/translations/ca.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3.", "unknown": "Error inesperat" }, @@ -11,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token d'API", + "description": "Torna a autenticar-te amb el compte de Cloudflare." + } + }, "records": { "data": { "records": "Registres" diff --git a/homeassistant/components/cloudflare/translations/cs.json b/homeassistant/components/cloudflare/translations/cs.json index e20f26236be..8f88377860b 100644 --- a/homeassistant/components/cloudflare/translations/cs.json +++ b/homeassistant/components/cloudflare/translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace.", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, @@ -11,6 +12,11 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API token" + } + }, "records": { "data": { "records": "Z\u00e1znamy" diff --git a/homeassistant/components/cloudflare/translations/de.json b/homeassistant/components/cloudflare/translations/de.json index d03f293b38b..98cdbe355f6 100644 --- a/homeassistant/components/cloudflare/translations/de.json +++ b/homeassistant/components/cloudflare/translations/de.json @@ -26,7 +26,7 @@ }, "user": { "data": { - "api_token": "API Token" + "api_token": "API-Token" }, "description": "F\u00fcr diese Integration ist ein API-Token erforderlich, der mit Zone: Zone: Lesen und Zone: DNS: Bearbeiten f\u00fcr alle Zonen in deinem Konto erstellt wurde.", "title": "Mit Cloudflare verbinden" diff --git a/homeassistant/components/cloudflare/translations/es.json b/homeassistant/components/cloudflare/translations/es.json index 7f9fdc15dfb..0647609e4e8 100644 --- a/homeassistant/components/cloudflare/translations/es.json +++ b/homeassistant/components/cloudflare/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "unknown": "Error inesperado" }, @@ -11,6 +12,12 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Vuelva a autenticarse con su cuenta de Cloudflare." + } + }, "records": { "data": { "records": "Registros" diff --git a/homeassistant/components/cloudflare/translations/fr.json b/homeassistant/components/cloudflare/translations/fr.json index be6d4c3e2b3..677dc8552fb 100644 --- a/homeassistant/components/cloudflare/translations/fr.json +++ b/homeassistant/components/cloudflare/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible.", "unknown": "Erreur inattendue" }, @@ -11,6 +12,12 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Jeton API", + "description": "R\u00e9-authentifiez-vous avec votre compte Cloudflare." + } + }, "records": { "data": { "records": "Enregistrements" diff --git a/homeassistant/components/cloudflare/translations/he.json b/homeassistant/components/cloudflare/translations/he.json index 445cf45325d..fb0a20a223b 100644 --- a/homeassistant/components/cloudflare/translations/he.json +++ b/homeassistant/components/cloudflare/translations/he.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, @@ -11,6 +12,11 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" + } + }, "records": { "data": { "records": "\u05e8\u05e9\u05d5\u05de\u05d5\u05ea" diff --git a/homeassistant/components/cloudflare/translations/hu.json b/homeassistant/components/cloudflare/translations/hu.json index a0f250376da..ef2a47e2e0d 100644 --- a/homeassistant/components/cloudflare/translations/hu.json +++ b/homeassistant/components/cloudflare/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, @@ -11,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "API token", + "description": "Hiteles\u00edtse \u00fajra Cloudflare-fi\u00f3kj\u00e1val." + } + }, "records": { "data": { "records": "Rekordok" @@ -21,6 +28,7 @@ "data": { "api_token": "API Token" }, + "description": "Ehhez az integr\u00e1ci\u00f3hoz a Z\u00f3na: Z\u00f3na: Olvas\u00e1s \u00e9s Z\u00f3na: DNS: L\u00e9trehozott API-token sz\u00fcks\u00e9ges. A fi\u00f3k \u00f6sszes z\u00f3n\u00e1j\u00e1nak enged\u00e9lyeinek szerkeszt\u00e9se.", "title": "Csatlakoz\u00e1s a Cloudflare szolg\u00e1ltat\u00e1shoz" }, "zone": { diff --git a/homeassistant/components/cloudflare/translations/id.json b/homeassistant/components/cloudflare/translations/id.json index 98286398ea8..c7878017de3 100644 --- a/homeassistant/components/cloudflare/translations/id.json +++ b/homeassistant/components/cloudflare/translations/id.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Autentikasi ulang berhasil", "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan.", "unknown": "Kesalahan yang tidak diharapkan" }, @@ -11,6 +12,11 @@ }, "flow_title": "Cloudflare: {name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API" + } + }, "records": { "data": { "records": "Catatan" diff --git a/homeassistant/components/cloudflare/translations/it.json b/homeassistant/components/cloudflare/translations/it.json index df5c045dd99..fae00a790dc 100644 --- a/homeassistant/components/cloudflare/translations/it.json +++ b/homeassistant/components/cloudflare/translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione.", "unknown": "Errore imprevisto" }, @@ -11,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Eseguire nuovamente l'autenticazione con l'account Cloudflare." + } + }, "records": { "data": { "records": "Record" diff --git a/homeassistant/components/cloudflare/translations/pl.json b/homeassistant/components/cloudflare/translations/pl.json index 7675c7df8a3..94a87c34b2e 100644 --- a/homeassistant/components/cloudflare/translations/pl.json +++ b/homeassistant/components/cloudflare/translations/pl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja.", "unknown": "Nieoczekiwany b\u0142\u0105d" }, @@ -11,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "reauth_confirm": { + "data": { + "api_token": "Token API", + "description": "Ponownie uwierzytelnij za pomoc\u0105 konta Cloudflare." + } + }, "records": { "data": { "records": "Rekordy" diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py index 3968ebbe9d7..651c584bc4c 100644 --- a/homeassistant/components/cmus/media_player.py +++ b/homeassistant/components/cmus/media_player.py @@ -98,6 +98,9 @@ class CmusRemote: class CmusDevice(MediaPlayerEntity): """Representation of a running cmus.""" + _attr_media_content_type = MEDIA_TYPE_MUSIC + _attr_supported_features = SUPPORT_CMUS + def __init__(self, device, name, server): """Initialize the CMUS device.""" @@ -106,7 +109,7 @@ class CmusDevice(MediaPlayerEntity): auto_name = f"cmus-{server}" else: auto_name = "cmus-local" - self._name = name or auto_name + self._attr_name = name or auto_name self.status = {} def update(self): @@ -120,80 +123,30 @@ class CmusDevice(MediaPlayerEntity): self._remote.connect() else: self.status = status + if self.status.get("status") == "playing": + self._attr_state = STATE_PLAYING + elif self.status.get("status") == "paused": + self._attr_state = STATE_PAUSED + else: + self._attr_state = STATE_OFF + self._attr_media_content_id = self.status.get("file") + self._attr_media_duration = self.status.get("duration") + self._attr_media_title = self.status["tag"].get("title") + self._attr_media_artist = self.status["tag"].get("artist") + self._attr_media_track = self.status["tag"].get("tracknumber") + self._attr_media_album_name = self.status["tag"].get("album") + self._attr_media_album_artist = self.status["tag"].get("albumartist") + left = self.status["set"].get("vol_left")[0] + right = self.status["set"].get("vol_right")[0] + if left != right: + volume = float(left + right) / 2 + else: + volume = left + self._attr_volume_level = int(volume) / 100 return _LOGGER.warning("Received no status from cmus") - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the media state.""" - if self.status.get("status") == "playing": - return STATE_PLAYING - if self.status.get("status") == "paused": - return STATE_PAUSED - return STATE_OFF - - @property - def media_content_id(self): - """Content ID of current playing media.""" - return self.status.get("file") - - @property - def content_type(self): - """Content type of the current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def media_duration(self): - """Duration of current playing media in seconds.""" - return self.status.get("duration") - - @property - def media_title(self): - """Title of current playing media.""" - return self.status["tag"].get("title") - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self.status["tag"].get("artist") - - @property - def media_track(self): - """Track number of current playing media, music track only.""" - return self.status["tag"].get("tracknumber") - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self.status["tag"].get("album") - - @property - def media_album_artist(self): - """Album artist of current playing media, music track only.""" - return self.status["tag"].get("albumartist") - - @property - def volume_level(self): - """Return the volume level.""" - left = self.status["set"].get("vol_left")[0] - right = self.status["set"].get("vol_right")[0] - if left != right: - volume = float(left + right) / 2 - else: - volume = left - return int(volume) / 100 - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_CMUS - def turn_off(self): """Service to send the CMUS the command to stop playing.""" self._remote.cmus.player_stop() diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index a9c6422b4c6..26f41ac2e67 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1 +1,149 @@ -"""The co2signal component.""" +"""The CO2 Signal integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import TypedDict, cast + +import CO2Signal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTRY_CODE, DOMAIN +from .util import get_extra_name + +PLATFORMS = ["sensor"] +_LOGGER = logging.getLogger(__name__) + + +class CO2SignalData(TypedDict): + """Data field.""" + + carbonIntensity: float + fossilFuelPercentage: float + + +class CO2SignalUnit(TypedDict): + """Unit field.""" + + carbonIntensity: str + + +class CO2SignalResponse(TypedDict): + """API response.""" + + status: str + countryCode: str + data: CO2SignalData + units: CO2SignalUnit + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up CO2 Signal from a config entry.""" + coordinator = CO2SignalCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(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) + + +class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + def get_extra_name(self) -> str | None: + """Return the extra name describing the location if not home.""" + return get_extra_name(self._entry.data) + + async def _async_update_data(self) -> CO2SignalResponse: + """Fetch the latest data from the source.""" + try: + data = await self.hass.async_add_executor_job( + get_data, self.hass, self._entry.data + ) + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + except CO2Error as err: + raise UpdateFailed(str(err)) from err + + return data + + +class CO2Error(HomeAssistantError): + """Base error.""" + + +class InvalidAuth(CO2Error): + """Raised when invalid authentication credentials are provided.""" + + +class APIRatelimitExceeded(CO2Error): + """Raised when the API rate limit is exceeded.""" + + +class UnknownError(CO2Error): + """Raised when an unknown error occurs.""" + + +def get_data(hass: HomeAssistant, config: dict) -> CO2SignalResponse: + """Get data from the API.""" + if CONF_COUNTRY_CODE in config: + latitude = None + longitude = None + else: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + try: + data = CO2Signal.get_latest( + config[CONF_API_KEY], + config.get(CONF_COUNTRY_CODE), + latitude, + longitude, + wait=False, + ) + + except ValueError as err: + err_str = str(err) + + if "Invalid authentication credentials" in err_str: + raise InvalidAuth from err + if "API rate limit exceeded." in err_str: + raise APIRatelimitExceeded from err + + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + except Exception as err: + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + + else: + if "error" in data: + raise UnknownError(data["error"]) + + if data.get("status") != "ok": + _LOGGER.exception("Unexpected response: %s", data) + raise UnknownError + + return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py new file mode 100644 index 00000000000..e3862d6347c --- /dev/null +++ b/homeassistant/components/co2signal/config_flow.py @@ -0,0 +1,190 @@ +"""Config flow for Co2signal integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from . import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError, get_data +from .const import CONF_COUNTRY_CODE, DOMAIN +from .util import get_extra_name + +_LOGGER = logging.getLogger(__name__) + +TYPE_USE_HOME = "Use home location" +TYPE_SPECIFY_COORDINATES = "Specify coordinates" +TYPE_SPECIFY_COUNTRY = "Specify country code" + + +def _get_entry_type(config: dict) -> str: + """Get entry type from the configuration.""" + if CONF_LATITUDE in config: + return TYPE_SPECIFY_COORDINATES + + if CONF_COUNTRY_CODE in config: + return TYPE_SPECIFY_COUNTRY + + return TYPE_USE_HOME + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Co2signal.""" + + VERSION = 1 + _data: dict | None + + async def async_step_import(self, import_info): + """Set the config entry up from yaml.""" + data = {CONF_API_KEY: import_info[CONF_TOKEN]} + + if CONF_COUNTRY_CODE in import_info: + data[CONF_COUNTRY_CODE] = import_info[CONF_COUNTRY_CODE] + new_entry_type = TYPE_SPECIFY_COUNTRY + elif ( + CONF_LATITUDE in import_info + and import_info[CONF_LATITUDE] != self.hass.config.latitude + and import_info[CONF_LONGITUDE] != self.hass.config.longitude + ): + data[CONF_LATITUDE] = import_info[CONF_LATITUDE] + data[CONF_LONGITUDE] = import_info[CONF_LONGITUDE] + new_entry_type = TYPE_SPECIFY_COORDINATES + else: + new_entry_type = TYPE_USE_HOME + + for entry in self._async_current_entries(include_ignore=False): + + if (cur_entry_type := _get_entry_type(entry.data)) != new_entry_type: + continue + + if cur_entry_type == TYPE_USE_HOME and new_entry_type == TYPE_USE_HOME: + return self.async_abort(reason="already_configured") + + if ( + cur_entry_type == TYPE_SPECIFY_COUNTRY + and data[CONF_COUNTRY_CODE] == entry.data[CONF_COUNTRY_CODE] + ): + return self.async_abort(reason="already_configured") + + if ( + cur_entry_type == TYPE_SPECIFY_COORDINATES + and data[CONF_LATITUDE] == entry.data[CONF_LATITUDE] + and data[CONF_LONGITUDE] == entry.data[CONF_LONGITUDE] + ): + return self.async_abort(reason="already_configured") + + try: + await self.hass.async_add_executor_job(get_data, self.hass, data) + except CO2Error: + return self.async_abort(reason="unknown") + + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", data=data + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + data_schema = vol.Schema( + { + vol.Required("location", default=TYPE_USE_HOME): vol.In( + ( + TYPE_USE_HOME, + TYPE_SPECIFY_COORDINATES, + TYPE_SPECIFY_COUNTRY, + ) + ), + vol.Required(CONF_API_KEY): cv.string, + } + ) + + if user_input is None: + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + + if user_input["location"] == TYPE_SPECIFY_COORDINATES: + self._data = data + return await self.async_step_coordinates() + + if user_input["location"] == TYPE_SPECIFY_COUNTRY: + self._data = data + return await self.async_step_country() + + return await self._validate_and_create("user", data_schema, data) + + async def async_step_coordinates( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Validate coordinates.""" + data_schema = vol.Schema( + { + vol.Required( + CONF_LATITUDE, + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, + ): cv.longitude, + } + ) + if user_input is None: + return self.async_show_form(step_id="coordinates", data_schema=data_schema) + + assert self._data is not None + + return await self._validate_and_create( + "coordinates", data_schema, {**self._data, **user_input} + ) + + async def async_step_country( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Validate country.""" + data_schema = vol.Schema( + { + vol.Required(CONF_COUNTRY_CODE): cv.string, + } + ) + if user_input is None: + return self.async_show_form(step_id="country", data_schema=data_schema) + + assert self._data is not None + + return await self._validate_and_create( + "country", data_schema, {**self._data, **user_input} + ) + + async def _validate_and_create( + self, step_id: str, data_schema: vol.Schema, data: dict + ) -> FlowResult: + """Validate data and show form if it is invalid.""" + errors: dict[str, str] = {} + + try: + await self.hass.async_add_executor_job(get_data, self.hass, data) + except InvalidAuth: + errors["base"] = "invalid_auth" + except APIRatelimitExceeded: + errors["base"] = "api_ratelimit" + except UnknownError: + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=get_extra_name(data) or "CO2 Signal", + data=data, + ) + + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/co2signal/const.py b/homeassistant/components/co2signal/const.py new file mode 100644 index 00000000000..1db0ccc20fd --- /dev/null +++ b/homeassistant/components/co2signal/const.py @@ -0,0 +1,11 @@ +"""Constants for the Co2signal integration.""" + + +DOMAIN = "co2signal" +CONF_COUNTRY_CODE = "country_code" +ATTRIBUTION = "Data provided by CO2signal" +MSG_LOCATION = ( + "Please use either coordinates or the country code. " + "For the coordinates, " + "you need to use both latitude and longitude." +) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 50ed7f62038..1921ae4f575 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -2,7 +2,10 @@ "domain": "co2signal", "name": "CO2 Signal", "documentation": "https://www.home-assistant.io/integrations/co2signal", - "requirements": ["co2signal==0.4.2"], + "requirements": [ + "co2signal==0.4.2" + ], "codeowners": [], - "iot_class": "cloud_polling" -} + "iot_class": "cloud_polling", + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index e9cfdb87983..bd8d94355fd 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,33 +1,36 @@ """Support for the CO2signal platform.""" -from datetime import timedelta -import logging +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import cast -import CO2Signal import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant import config_entries +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( ATTR_ATTRIBUTION, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_TOKEN, - ENERGY_KILO_WATT_HOUR, + PERCENTAGE, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, update_coordinator +from homeassistant.helpers.typing import StateType -CONF_COUNTRY_CODE = "country_code" +from . import CO2SignalCoordinator, CO2SignalResponse +from .const import ATTRIBUTION, CONF_COUNTRY_CODE, DOMAIN, MSG_LOCATION -_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=3) -ATTRIBUTION = "Data provided by CO2signal" - -MSG_LOCATION = ( - "Please use either coordinates or the country code. " - "For the coordinates, " - "you need to use both latitude and longitude." -) -CO2_INTENSITY_UNIT = f"CO2eq/{ENERGY_KILO_WATT_HOUR}" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_TOKEN): cv.string, @@ -38,69 +41,94 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +@dataclass +class CO2SensorEntityDescription: + """Provide a description of a CO2 sensor.""" + + key: str + name: str + unit_of_measurement: str | None = None + # For backwards compat, allow description to override unique ID key to use + unique_id: str | None = None + + +SENSORS = ( + CO2SensorEntityDescription( + key="carbonIntensity", + name="CO2 intensity", + unique_id="co2intensity", + # No unit, it's extracted from response. + ), + CO2SensorEntityDescription( + key="fossilFuelPercentage", + name="Grid fossil fuel percentage", + unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): """Set up the CO2signal sensor.""" - token = config[CONF_TOKEN] - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - country_code = config.get(CONF_COUNTRY_CODE) - - _LOGGER.debug("Setting up the sensor using the %s", country_code) - - devs = [] - - devs.append(CO2Sensor(token, country_code, lat, lon)) - add_entities(devs, True) + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=config, + ) -class CO2Sensor(SensorEntity): +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the CO2signal sensor.""" + coordinator: CO2SignalCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities(CO2Sensor(coordinator, description) for description in SENSORS) + + +class CO2Sensor(update_coordinator.CoordinatorEntity[CO2SignalResponse], SensorEntity): """Implementation of the CO2Signal sensor.""" + _attr_state_class = STATE_CLASS_MEASUREMENT _attr_icon = "mdi:molecule-co2" - _attr_unit_of_measurement = CO2_INTENSITY_UNIT - def __init__(self, token, country_code, lat, lon): + def __init__( + self, coordinator: CO2SignalCoordinator, description: CO2SensorEntityDescription + ) -> None: """Initialize the sensor.""" - self._token = token - self._country_code = country_code - self._latitude = lat - self._longitude = lon - self._data = None + super().__init__(coordinator) + self._description = description - if country_code is not None: - device_name = country_code - else: - device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}" + name = description.name + if extra_name := coordinator.get_extra_name(): + name = f"{extra_name} - {name}" - self._friendly_name = f"CO2 intensity - {device_name}" + self._attr_name = name + self._attr_extra_state_attributes = { + "country_code": coordinator.data["countryCode"], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + self._attr_device_info = { + ATTR_IDENTIFIERS: {(DOMAIN, coordinator.entry_id)}, + ATTR_NAME: "CO2 signal", + ATTR_MANUFACTURER: "Tmrow.com", + "entry_type": "service", + } + self._attr_unique_id = ( + f"{coordinator.entry_id}_{description.unique_id or description.key}" + ) @property - def name(self): - """Return the name of the sensor.""" - return self._friendly_name + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available and self._description.key in self.coordinator.data["data"] + ) @property - def state(self): - """Return the state of the device.""" - return self._data + def state(self) -> StateType: + """Return sensor state.""" + return round(self.coordinator.data["data"][self._description.key], 2) # type: ignore[misc] @property - def extra_state_attributes(self): - """Return the state attributes of the last update.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - def update(self): - """Get the latest data and updates the states.""" - - _LOGGER.debug("Update data for %s", self._friendly_name) - - if self._country_code is not None: - self._data = CO2Signal.get_latest_carbon_intensity( - self._token, country_code=self._country_code - ) - else: - self._data = CO2Signal.get_latest_carbon_intensity( - self._token, latitude=self._latitude, longitude=self._longitude - ) - - self._data = round(self._data, 2) + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement.""" + if self._description.unit_of_measurement: + return self._description.unit_of_measurement + return cast(str, self.coordinator.data["units"].get(self._description.key)) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json new file mode 100644 index 00000000000..2fe5b79c907 --- /dev/null +++ b/homeassistant/components/co2signal/strings.json @@ -0,0 +1,34 @@ +{ + "config": { + "step": { + "user": { + "data": { + "location": "Get data for", + "api_key": "[%key:common::config_flow::data::access_token%]" + }, + "description": "Visit https://co2signal.com/ to request a token." + }, + "coordinates": { + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]" + } + }, + "country": { + "data": { + "country_code": "Country code" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "api_ratelimit": "API Ratelimit exceeded" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "api_ratelimit": "API Ratelimit exceeded" + } + } +} diff --git a/homeassistant/components/co2signal/translations/ca.json b/homeassistant/components/co2signal/translations/ca.json new file mode 100644 index 00000000000..8a9539cfa97 --- /dev/null +++ b/homeassistant/components/co2signal/translations/ca.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "unknown": "Error inesperat" + }, + "error": { + "api_ratelimit": "S'ha superat la taxa l\u00edmit d'API", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitud", + "longitude": "Longitud" + } + }, + "country": { + "data": { + "country_code": "Codi de pa\u00eds" + } + }, + "user": { + "data": { + "api_key": "Token d'acc\u00e9s", + "location": "Obt\u00e9 dades per" + }, + "description": "Visita https://co2signal.com/ per demanar un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/cs.json b/homeassistant/components/co2signal/translations/cs.json new file mode 100644 index 00000000000..954168d1ee2 --- /dev/null +++ b/homeassistant/components/co2signal/translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + } + }, + "country": { + "data": { + "country_code": "K\u00f3d zem\u011b" + } + }, + "user": { + "data": { + "api_key": "P\u0159\u00edstupov\u00fd token" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/de.json b/homeassistant/components/co2signal/translations/de.json new file mode 100644 index 00000000000..e35b991566f --- /dev/null +++ b/homeassistant/components/co2signal/translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "unknown": "Unerwarteter Fehler" + }, + "error": { + "api_ratelimit": "API Ratelimit \u00fcberschritten", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + } + }, + "country": { + "data": { + "country_code": "L\u00e4ndercode" + } + }, + "user": { + "data": { + "api_key": "Zugangstoken", + "location": "Daten abrufen f\u00fcr" + }, + "description": "Besuche https://co2signal.com/, um ein Token anzufordern." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/en.json b/homeassistant/components/co2signal/translations/en.json new file mode 100644 index 00000000000..3d8cc7c9d9f --- /dev/null +++ b/homeassistant/components/co2signal/translations/en.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "api_ratelimit": "API Ratelimit exceeded", + "unknown": "Unexpected error" + }, + "error": { + "api_ratelimit": "API Ratelimit exceeded", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "country": { + "data": { + "country_code": "Country code" + } + }, + "user": { + "data": { + "api_key": "Access Token", + "location": "Get data for" + }, + "description": "Visit https://co2signal.com/ to request a token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/et.json b/homeassistant/components/co2signal/translations/et.json new file mode 100644 index 00000000000..a0d8f9db27f --- /dev/null +++ b/homeassistant/components/co2signal/translations/et.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "unknown": "Ootamatu t\u00f5rge" + }, + "error": { + "api_ratelimit": "API p\u00e4rigute limiit on \u00fcletatud", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Laiuskraad", + "longitude": "Pikkuskraad" + } + }, + "country": { + "data": { + "country_code": "Riigi kood" + } + }, + "user": { + "data": { + "api_key": "Juurdep\u00e4\u00e4sut\u00f5end", + "location": "Hangi andmed" + }, + "description": "Loa taotlemiseks k\u00fclasta https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/fr.json b/homeassistant/components/co2signal/translations/fr.json new file mode 100644 index 00000000000..4b36bd3bd74 --- /dev/null +++ b/homeassistant/components/co2signal/translations/fr.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "api_ratelimit": "Limite de d\u00e9bit de l\u2019API d\u00e9pass\u00e9e", + "unknown": "Erreur inattendue" + }, + "error": { + "api_ratelimit": "Limite de d\u00e9bit API d\u00e9pass\u00e9e", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitude", + "longitude": "Longitude" + } + }, + "country": { + "data": { + "country_code": "Code pays" + } + }, + "user": { + "data": { + "api_key": "Token d'acc\u00e8s", + "location": "Obtenir des donn\u00e9es pour" + }, + "description": "Visitez https://co2signal.com/ pour demander un jeton." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/he.json b/homeassistant/components/co2signal/translations/he.json new file mode 100644 index 00000000000..9ff327c584b --- /dev/null +++ b/homeassistant/components/co2signal/translations/he.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da" + } + }, + "country": { + "data": { + "country_code": "\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05de\u05d3\u05d9\u05e0\u05d4" + } + }, + "user": { + "data": { + "api_key": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/it.json b/homeassistant/components/co2signal/translations/it.json new file mode 100644 index 00000000000..0db63a1e912 --- /dev/null +++ b/homeassistant/components/co2signal/translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "api_ratelimit": "Limite di frequenza API superato", + "unknown": "Errore imprevisto" + }, + "error": { + "api_ratelimit": "Limite di frequenza API superato", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Latitudine", + "longitude": "Logitudine" + } + }, + "country": { + "data": { + "country_code": "Prefisso internazionale" + } + }, + "user": { + "data": { + "api_key": "Token di accesso", + "location": "Ottieni dati per" + }, + "description": "Visita https://co2signal.com/ per richiedere un token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/nl.json b/homeassistant/components/co2signal/translations/nl.json new file mode 100644 index 00000000000..54a7cd110cc --- /dev/null +++ b/homeassistant/components/co2signal/translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "api_ratelimit": "API Ratelimit overschreden", + "unknown": "Onverwachte fout" + }, + "error": { + "api_ratelimit": "API Ratelimit overschreden", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Breedtegraad", + "longitude": "Lengtegraad" + } + }, + "country": { + "data": { + "country_code": "Landcode" + } + }, + "user": { + "data": { + "api_key": "Toegangstoken", + "location": "Gegevens ophalen voor" + }, + "description": "Ga naar https://co2signal.com/ om een token aan te vragen." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/pl.json b/homeassistant/components/co2signal/translations/pl.json new file mode 100644 index 00000000000..3b243649180 --- /dev/null +++ b/homeassistant/components/co2signal/translations/pl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "api_ratelimit": "Przekroczono limit interfejsu API", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "error": { + "api_ratelimit": "Przekroczono limit interfejsu API", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "coordinates": { + "data": { + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna" + } + }, + "country": { + "data": { + "country_code": "Kod kraju" + } + }, + "user": { + "data": { + "api_key": "Token dost\u0119pu", + "location": "Pobierz dane dla" + }, + "description": "Odwied\u017a https://co2signal.com/, aby uzyska\u0107 token." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/ru.json b/homeassistant/components/co2signal/translations/ru.json new file mode 100644 index 00000000000..c2be73b3c26 --- /dev/null +++ b/homeassistant/components/co2signal/translations/ru.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "error": { + "api_ratelimit": "\u041f\u0440\u0435\u0432\u044b\u0448\u0435\u043d \u043f\u0440\u0435\u0434\u0435\u043b \u0441\u043a\u043e\u0440\u043e\u0441\u0442\u0438 API.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" + } + }, + "country": { + "data": { + "country_code": "\u041a\u043e\u0434 \u0441\u0442\u0440\u0430\u043d\u044b" + } + }, + "user": { + "data": { + "api_key": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", + "location": "\u041f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0445 \u0434\u043b\u044f" + }, + "description": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0430 \u0441\u0430\u0439\u0442\u0435 https://co2signal.com/." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/translations/zh-Hant.json b/homeassistant/components/co2signal/translations/zh-Hant.json new file mode 100644 index 00000000000..39cee0da0e5 --- /dev/null +++ b/homeassistant/components/co2signal/translations/zh-Hant.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "error": { + "api_ratelimit": "\u8d85\u904e API \u5b58\u53d6\u9650\u5236", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "coordinates": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6" + } + }, + "country": { + "data": { + "country_code": "\u570b\u78bc" + } + }, + "user": { + "data": { + "api_key": "\u5b58\u53d6\u6b0a\u6756", + "location": "\u53d6\u5f97\u8cc7\u6599\uff1a" + }, + "description": "\u700f\u89bd https://co2signal.com/ \u4ee5\u7372\u5f97\u6b0a\u6756\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/co2signal/util.py b/homeassistant/components/co2signal/util.py new file mode 100644 index 00000000000..af0bec34904 --- /dev/null +++ b/homeassistant/components/co2signal/util.py @@ -0,0 +1,19 @@ +"""Utils for CO2 signal.""" +from __future__ import annotations + +from collections.abc import Mapping + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from .const import CONF_COUNTRY_CODE + + +def get_extra_name(config: Mapping) -> str | None: + """Return the extra name describing the location if not home.""" + if CONF_COUNTRY_CODE in config: + return config[CONF_COUNTRY_CODE] + + if CONF_LATITUDE in config: + return f"{round(config[CONF_LATITUDE], 2)}, {round(config[CONF_LONGITUDE], 2)}" + + return None diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index 08b97756dff..033c398e09c 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -20,6 +20,7 @@ from .const import ( API_ACCOUNT_ID, API_ACCOUNTS_DATA, CONF_CURRENCIES, + CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, CONF_YAML_API_TOKEN, DOMAIN, @@ -67,9 +68,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Coinbase from a config entry.""" - instance = await hass.async_add_executor_job( - create_and_update_instance, entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN] - ) + instance = await hass.async_add_executor_job(create_and_update_instance, entry) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -91,10 +90,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -def create_and_update_instance(api_key, api_token): +def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData: """Create and update a Coinbase Data instance.""" - client = Client(api_key, api_token) - instance = CoinbaseData(client) + client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN]) + base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD") + instance = CoinbaseData(client, base_rate) instance.update() return instance @@ -139,11 +139,12 @@ def get_accounts(client): class CoinbaseData: """Get the latest data and update the states.""" - def __init__(self, client): + def __init__(self, client, exchange_base): """Init the coinbase data object.""" self.client = client self.accounts = None + self.exchange_base = exchange_base self.exchange_rates = None self.user_id = self.client.get_current_user()[API_ACCOUNT_ID] @@ -153,7 +154,9 @@ class CoinbaseData: try: self.accounts = get_accounts(self.client) - self.exchange_rates = self.client.get_exchange_rates() + self.exchange_rates = self.client.get_exchange_rates( + currency=self.exchange_base + ) except AuthenticationError as coinbase_error: _LOGGER.error( "Authentication error connecting to coinbase: %s", coinbase_error diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index adfa9977518..4ea36dad266 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -15,6 +15,7 @@ from .const import ( API_ACCOUNT_CURRENCY, API_RATES, CONF_CURRENCIES, + CONF_EXCHANGE_BASE, CONF_EXCHANGE_RATES, CONF_OPTIONS, CONF_YAML_API_TOKEN, @@ -156,6 +157,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): errors = {} default_currencies = self.config_entry.options.get(CONF_CURRENCIES, []) default_exchange_rates = self.config_entry.options.get(CONF_EXCHANGE_RATES, []) + default_exchange_base = self.config_entry.options.get(CONF_EXCHANGE_BASE, "USD") if user_input is not None: # Pass back user selected options, even if bad @@ -165,6 +167,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if CONF_EXCHANGE_RATES in user_input: default_exchange_rates = user_input[CONF_EXCHANGE_RATES] + if CONF_EXCHANGE_RATES in user_input: + default_exchange_base = user_input[CONF_EXCHANGE_BASE] + try: await validate_options(self.hass, self.config_entry, user_input) except CurrencyUnavaliable: @@ -189,6 +194,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_EXCHANGE_RATES, default=default_exchange_rates, ): cv.multi_select(RATES), + vol.Optional( + CONF_EXCHANGE_BASE, + default=default_exchange_base, + ): vol.In(WALLETS), } ), errors=errors, diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 035706c46ce..a7ed0b15986 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -1,6 +1,7 @@ """Constants used for Coinbase.""" CONF_CURRENCIES = "account_balance_currencies" +CONF_EXCHANGE_BASE = "exchange_base" CONF_EXCHANGE_RATES = "exchange_rate_currencies" CONF_OPTIONS = "options" DOMAIN = "coinbase" diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 13981619051..c86f21bac1d 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if CONF_CURRENCIES in config_entry.options: desired_currencies = config_entry.options[CONF_CURRENCIES] - exchange_native_currency = instance.exchange_rates[API_ACCOUNT_CURRENCY] + exchange_base_currency = instance.exchange_rates[API_ACCOUNT_CURRENCY] for currency in desired_currencies: if currency not in provided_currencies: @@ -67,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ExchangeRateSensor( instance, rate, - exchange_native_currency, + exchange_base_currency, ) ) @@ -149,7 +149,7 @@ class AccountSensor(SensorEntity): class ExchangeRateSensor(SensorEntity): """Representation of a Coinbase.com sensor.""" - def __init__(self, coinbase_data, exchange_currency, native_currency): + def __init__(self, coinbase_data, exchange_currency, exchange_base): """Initialize the sensor.""" self._coinbase_data = coinbase_data self.currency = exchange_currency @@ -158,7 +158,7 @@ class ExchangeRateSensor(SensorEntity): self._state = round( 1 / float(self._coinbase_data.exchange_rates[API_RATES][self.currency]), 2 ) - self._unit_of_measurement = native_currency + self._unit_of_measurement = exchange_base @property def name(self): diff --git a/homeassistant/components/coinbase/strings.json b/homeassistant/components/coinbase/strings.json index 399bfbd894a..ce80db35918 100644 --- a/homeassistant/components/coinbase/strings.json +++ b/homeassistant/components/coinbase/strings.json @@ -25,7 +25,8 @@ "description": "Adjust Coinbase Options", "data": { "account_balance_currencies": "Wallet balances to report.", - "exchange_rate_currencies": "Exchange rates to report." + "exchange_rate_currencies": "Exchange rates to report.", + "exchange_base": "Base currency for exchange rate sensors." } } }, @@ -35,4 +36,4 @@ "exchange_rate_unavaliable": "One or more of the requested exchange rates is not provided by Coinbase." } } -} +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ar.json b/homeassistant/components/coinbase/translations/ar.json new file mode 100644 index 00000000000..30655126631 --- /dev/null +++ b/homeassistant/components/coinbase/translations/ar.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "\u0633\u0631 API", + "exchange_rates": "\u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641" + }, + "description": "\u064a\u0631\u062c\u0649 \u0625\u062f\u062e\u0627\u0644 \u062a\u0641\u0627\u0635\u064a\u0644 \u0645\u0641\u062a\u0627\u062d API \u0627\u0644\u062e\u0627\u0635 \u0628\u0643 \u0639\u0644\u0649 \u0627\u0644\u0646\u062d\u0648 \u0627\u0644\u0645\u0646\u0635\u0648\u0635 \u0639\u0644\u064a\u0647 \u0645\u0646 \u0642\u0628\u0644 Coinbase.", + "title": "\u062a\u0641\u0627\u0635\u064a\u0644 \u0645\u0641\u062a\u0627\u062d Coinbase API" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "\u0644\u0627 \u064a\u062a\u0645 \u062a\u0648\u0641\u064a\u0631 \u0648\u0627\u062d\u062f \u0623\u0648 \u0623\u0643\u062b\u0631 \u0645\u0646 \u0623\u0631\u0635\u062f\u0629 \u0627\u0644\u0639\u0645\u0644\u0627\u062a \u0627\u0644\u0645\u0637\u0644\u0648\u0628\u0629 \u0628\u0648\u0627\u0633\u0637\u0629 Coinbase API \u0627\u0644\u062e\u0627\u0635 \u0628\u0643.", + "exchange_rate_unavaliable": "\u0644\u0627 \u064a\u062a\u0645 \u062a\u0648\u0641\u064a\u0631 \u0648\u0627\u062d\u062f \u0623\u0648 \u0623\u0643\u062b\u0631 \u0645\u0646 \u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641 \u0627\u0644\u0645\u0637\u0644\u0648\u0628\u0629 \u0645\u0646 Coinbase.", + "unknown": "\u062d\u062f\u062b \u062e\u0637\u0623 \u063a\u064a\u0631 \u0645\u062a\u0648\u0642\u0639" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "\u0623\u0631\u0635\u062f\u0629 \u0627\u0644\u0645\u062d\u0641\u0638\u0629 \u0644\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646\u0647\u0627.", + "exchange_rate_currencies": "\u0623\u0633\u0639\u0627\u0631 \u0627\u0644\u0635\u0631\u0641 \u0644\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646\u0647\u0627." + }, + "description": "\u0636\u0628\u0637 \u062e\u064a\u0627\u0631\u0627\u062a Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ca.json b/homeassistant/components/coinbase/translations/ca.json new file mode 100644 index 00000000000..ca0214372fb --- /dev/null +++ b/homeassistant/components/coinbase/translations/ca.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "api_token": "Secret API", + "currencies": "Monedes del saldo del compte", + "exchange_rates": "Tipus de canvi" + }, + "description": "Introdueix els detalls de la teva clau API tal com els proporciona Coinbase.", + "title": "Detalls de la clau API de Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "L'API de Coinbase no proporciona algun/s dels saldos de moneda que has sol\u00b7licitat.", + "exchange_rate_unavaliable": "L'API de Coinbase no proporciona algun/s dels tipus de canvi que has sol\u00b7licitat.", + "unknown": "Error inesperat" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldos de cartera a informar.", + "exchange_base": "Moneda base per als sensors de canvi de tipus.", + "exchange_rate_currencies": "Tipus de canvi a informar." + }, + "description": "Ajusta les opcions de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/cs.json b/homeassistant/components/coinbase/translations/cs.json new file mode 100644 index 00000000000..24dc9ec4e14 --- /dev/null +++ b/homeassistant/components/coinbase/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "api_key": "Kl\u00ed\u010d API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/de.json b/homeassistant/components/coinbase/translations/de.json index 37ccdedd81a..45360acd288 100644 --- a/homeassistant/components/coinbase/translations/de.json +++ b/homeassistant/components/coinbase/translations/de.json @@ -16,14 +16,14 @@ "currencies": "Kontostand W\u00e4hrungen", "exchange_rates": "Wechselkurse" }, - "description": "Bitte gib die Details Ihres API-Schl\u00fcssels ein, wie von Coinbase bereitgestellt. Trenne mehrere W\u00e4hrungen mit einem Komma (z. B. \"BTC, EUR\")", + "description": "Bitte gib die Details deines API-Schl\u00fcssels ein, wie von Coinbase bereitgestellt.", "title": "Coinbase API Schl\u00fcssel Details" } } }, "options": { "error": { - "currency_unavaliable": "Eine oder mehrere der angeforderten W\u00e4hrungssalden werden von Ihrer Coinbase-API nicht bereitgestellt.", + "currency_unavaliable": "Eine oder mehrere der angeforderten W\u00e4hrungssalden werden von deiner Coinbase-API nicht bereitgestellt.", "exchange_rate_unavaliable": "Einer oder mehrere der angeforderten Wechselkurse werden nicht von Coinbase bereitgestellt.", "unknown": "Unerwarteter Fehler" }, @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Zu meldende Wallet-Guthaben.", + "exchange_base": "Basisw\u00e4hrung f\u00fcr Wechselkurssensoren.", "exchange_rate_currencies": "Zu meldende Wechselkurse." }, "description": "Coinbase-Optionen anpassen" diff --git a/homeassistant/components/coinbase/translations/en.json b/homeassistant/components/coinbase/translations/en.json index 12db6bf8a30..0c5b296bce0 100644 --- a/homeassistant/components/coinbase/translations/en.json +++ b/homeassistant/components/coinbase/translations/en.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Wallet balances to report.", + "exchange_base": "Base currency for exchange rate sensors.", "exchange_rate_currencies": "Exchange rates to report." }, "description": "Adjust Coinbase Options" diff --git a/homeassistant/components/coinbase/translations/es.json b/homeassistant/components/coinbase/translations/es.json new file mode 100644 index 00000000000..9948ef57020 --- /dev/null +++ b/homeassistant/components/coinbase/translations/es.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "api_token": "Secreto de la API", + "currencies": "Saldo de la cuenta Monedas", + "exchange_rates": "Tipos de cambio" + }, + "description": "Por favor, introduce los detalles de tu clave API tal y como te la ha proporcionado Coinbase.", + "title": "Detalles de la clave API de Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "La API de Coinbase no proporciona uno o m\u00e1s de los saldos de divisas solicitados.", + "exchange_rate_unavaliable": "Coinbase no proporciona uno o m\u00e1s de los tipos de cambio solicitados.", + "unknown": "Error inesperado" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldos de la cartera para informar.", + "exchange_base": "Moneda base para sensores de tipo de cambio.", + "exchange_rate_currencies": "Tipos de cambio a informar." + }, + "description": "Ajustar las opciones de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/et.json b/homeassistant/components/coinbase/translations/et.json index ce5ea46ce34..84673940cab 100644 --- a/homeassistant/components/coinbase/translations/et.json +++ b/homeassistant/components/coinbase/translations/et.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Rahakoti saldod teavitamine.", + "exchange_base": "Vahetuskursiandurite baasvaluuta.", "exchange_rate_currencies": "Vahetuskursside aruanne." }, "description": "Kohanda Coinbase'i valikuid" diff --git a/homeassistant/components/coinbase/translations/fr.json b/homeassistant/components/coinbase/translations/fr.json new file mode 100644 index 00000000000..e0ec1ae200d --- /dev/null +++ b/homeassistant/components/coinbase/translations/fr.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "api_key": "cl\u00e9 API", + "api_token": "API secr\u00e8te", + "currencies": "Devises du solde du compte", + "exchange_rates": "Taux d'\u00e9change" + }, + "description": "Veuillez saisir les d\u00e9tails de votre cl\u00e9 API tels que fournis par Coinbase.", + "title": "D\u00e9tails de la cl\u00e9 de l'API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Un ou plusieurs des soldes de devises demand\u00e9s ne sont pas fournis par votre API Coinbase.", + "exchange_rate_unavaliable": "Un ou plusieurs des taux de change demand\u00e9s ne sont pas fournis par Coinbase.", + "unknown": "Erreur inattendue" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Soldes du portefeuille \u00e0 d\u00e9clarer.", + "exchange_base": "Devise de base pour les capteurs de taux de change.", + "exchange_rate_currencies": "Taux de change \u00e0 d\u00e9clarer." + }, + "description": "Ajuster les options de Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/he.json b/homeassistant/components/coinbase/translations/he.json new file mode 100644 index 00000000000..3446e8e5ede --- /dev/null +++ b/homeassistant/components/coinbase/translations/he.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + }, + "options": { + "error": { + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/hu.json b/homeassistant/components/coinbase/translations/hu.json new file mode 100644 index 00000000000..5fb22f9be3b --- /dev/null +++ b/homeassistant/components/coinbase/translations/hu.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "Ismeretlen hiba" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs", + "api_token": "API jelsz\u00f3", + "currencies": "Sz\u00e1mlaegyenleg-p\u00e9nznemek", + "exchange_rates": "\u00c1rfolyamok" + }, + "description": "K\u00e9rj\u00fck, adja meg API kulcs\u00e1nak adatait a Coinbase \u00e1ltal megadott m\u00f3don.", + "title": "Coinbase API kulcs r\u00e9szletei" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "A k\u00e9rt valutaegyenlegek k\u00f6z\u00fcl egyet vagy t\u00f6bbet nem biztos\u00edt a Coinbase API.", + "exchange_rate_unavaliable": "A k\u00e9rt \u00e1rfolyamok k\u00f6z\u00fcl egyet vagy t\u00f6bbet a Coinbase nem biztos\u00edt.", + "unknown": "Ismeretlen hiba" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Jelentend\u0151 p\u00e9nzt\u00e1rca egyenlegek.", + "exchange_base": "Az \u00e1rfolyam-\u00e9rz\u00e9kel\u0151k alapvalut\u00e1ja.", + "exchange_rate_currencies": "Jelentend\u0151 \u00e1rfolyamok." + }, + "description": "\u00c1ll\u00edtsa be a Coinbase opci\u00f3kat" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/id.json b/homeassistant/components/coinbase/translations/id.json new file mode 100644 index 00000000000..e0d93019507 --- /dev/null +++ b/homeassistant/components/coinbase/translations/id.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "api_key": "Kunci API" + } + } + } + }, + "options": { + "error": { + "unknown": "Kesalahan yang tidak diharapkan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/it.json b/homeassistant/components/coinbase/translations/it.json new file mode 100644 index 00000000000..af5d07805de --- /dev/null +++ b/homeassistant/components/coinbase/translations/it.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "api_token": " API Segreta", + "currencies": "Valute del saldo del conto", + "exchange_rates": "Tassi di cambio" + }, + "description": "Inserisci i dettagli della tua chiave API come forniti da Coinbase.", + "title": "Dettagli della chiave API di Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Uno o pi\u00f9 saldi in valuta richiesti non sono forniti dalla tua API Coinbase.", + "exchange_rate_unavaliable": "Uno o pi\u00f9 dei tassi di cambio richiesti non sono forniti da Coinbase.", + "unknown": "Errore imprevisto" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Saldi del portafoglio da segnalare.", + "exchange_base": "Valuta di base per i sensori di tasso di cambio.", + "exchange_rate_currencies": "Tassi di cambio da segnalare." + }, + "description": "Regolare le opzioni di Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/nl.json b/homeassistant/components/coinbase/translations/nl.json index 052caf2f358..e277eaf67db 100644 --- a/homeassistant/components/coinbase/translations/nl.json +++ b/homeassistant/components/coinbase/translations/nl.json @@ -16,7 +16,7 @@ "currencies": "Valuta's van rekeningsaldo", "exchange_rates": "Wisselkoersen" }, - "description": "Voer de gegevens van uw API-sleutel in zoals verstrekt door Coinbase. Scheidt meerdere valuta's met een komma (bijv. \"BTC, EUR\")", + "description": "Voer de gegevens van uw API-sleutel in zoals verstrekt door Coinbase.", "title": "Coinbase API Sleutel Details" } } @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "Wallet-saldi om te rapporteren.", + "exchange_base": "Basisvaluta voor wisselkoerssensoren.", "exchange_rate_currencies": "Wisselkoersen om te rapporteren." }, "description": "Coinbase-opties aanpassen" diff --git a/homeassistant/components/coinbase/translations/no.json b/homeassistant/components/coinbase/translations/no.json index 265ea29e01c..747049fbd5c 100644 --- a/homeassistant/components/coinbase/translations/no.json +++ b/homeassistant/components/coinbase/translations/no.json @@ -16,7 +16,7 @@ "currencies": "Valutaer for kontosaldo", "exchange_rates": "Valutakurser" }, - "description": "Vennligst skriv inn detaljene for API-n\u00f8kkelen din som gitt av Coinbase. Skill flere valutaer med komma (f.eks. \"BTC, EUR\")", + "description": "Vennligst skriv inn detaljene for API-n\u00f8kkelen din som gitt av Coinbase.", "title": "Detaljer for Coinbase API-n\u00f8kkel" } } diff --git a/homeassistant/components/coinbase/translations/pl.json b/homeassistant/components/coinbase/translations/pl.json new file mode 100644 index 00000000000..8c269432b31 --- /dev/null +++ b/homeassistant/components/coinbase/translations/pl.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API", + "api_token": "Sekretne API", + "currencies": "Waluty salda konta", + "exchange_rates": "Kursy wymiany" + }, + "description": "Wprowad\u017a dane swojego klucza API podane przez Coinbase.", + "title": "Szczeg\u00f3\u0142y klucza API Coinbase" + } + } + }, + "options": { + "error": { + "currency_unavaliable": "Jeden lub wi\u0119cej \u017c\u0105danych sald walutowych nie jest dostarczanych przez interfejs API Coinbase.", + "exchange_rate_unavaliable": "Jeden lub wi\u0119cej z \u017c\u0105danych kurs\u00f3w wymiany nie jest dostarczany przez Coinbase.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "init": { + "data": { + "account_balance_currencies": "Salda portfela do zg\u0142oszenia.", + "exchange_base": "Waluta bazowa dla czujnik\u00f3w kurs\u00f3w walut.", + "exchange_rate_currencies": "Kursy walut do zg\u0142oszenia." + }, + "description": "Dostosuj opcje Coinbase" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/coinbase/translations/ru.json b/homeassistant/components/coinbase/translations/ru.json index 93bb203d24b..965cc5d0b96 100644 --- a/homeassistant/components/coinbase/translations/ru.json +++ b/homeassistant/components/coinbase/translations/ru.json @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "\u0411\u0430\u043b\u0430\u043d\u0441\u044b \u043a\u043e\u0448\u0435\u043b\u044c\u043a\u0430 \u0434\u043b\u044f \u043e\u0442\u0447\u0435\u0442\u043d\u043e\u0441\u0442\u0438.", + "exchange_base": "\u0411\u0430\u0437\u043e\u0432\u0430\u044f \u0432\u0430\u043b\u044e\u0442\u0430 \u0434\u043b\u044f \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u043e\u0431\u043c\u0435\u043d\u043d\u043e\u0433\u043e \u043a\u0443\u0440\u0441\u0430.", "exchange_rate_currencies": "\u041a\u0443\u0440\u0441\u044b \u0432\u0430\u043b\u044e\u0442 \u0434\u043b\u044f \u043e\u0442\u0447\u0435\u0442\u043d\u043e\u0441\u0442\u0438." }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u043e\u0432 Coinbase" diff --git a/homeassistant/components/coinbase/translations/zh-Hant.json b/homeassistant/components/coinbase/translations/zh-Hant.json index aa00e459591..5db1da7d23b 100644 --- a/homeassistant/components/coinbase/translations/zh-Hant.json +++ b/homeassistant/components/coinbase/translations/zh-Hant.json @@ -16,7 +16,7 @@ "currencies": "\u5e33\u6236\u9918\u984d\u8ca8\u5e63", "exchange_rates": "\u532f\u7387" }, - "description": "\u8acb\u8f38\u5165\u7531 Coinbase \u63d0\u4f9b\u7684 API \u5bc6\u9470\u8cc7\u8a0a\u3002\u4ee5\u9017\u865f\u5206\u9694\u591a\u7a2e\u8ca8\u5e63\uff08\u4f8b\u5982 \"BTC, EUR\"\uff09", + "description": "\u8acb\u8f38\u5165\u7531 Coinbase \u63d0\u4f9b\u7684 API \u5bc6\u9470\u8cc7\u8a0a\u3002", "title": "Coinbase API \u5bc6\u9470\u8cc7\u6599" } } @@ -31,6 +31,7 @@ "init": { "data": { "account_balance_currencies": "\u5e33\u6236\u9918\u984d\u56de\u5831\u503c\u3002", + "exchange_base": "\u532f\u7387\u50b3\u611f\u5668\u57fa\u6e96\u8ca8\u5e63\u3002", "exchange_rate_currencies": "\u532f\u7387\u56de\u5831\u503c\u3002" }, "description": "\u8abf\u6574 Coinbase \u9078\u9805" diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 38ee883e559..3ae79664953 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.20.3"], + "requirements": ["numpy==1.21.1"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index 89f4edc95d6..d9029dc497f 100644 --- a/homeassistant/components/config/core.py +++ b/homeassistant/components/config/core.py @@ -46,6 +46,7 @@ class CheckConfigView(HomeAssistantView): vol.Optional("time_zone"): cv.time_zone, vol.Optional("external_url"): vol.Any(cv.url, None), vol.Optional("internal_url"): vol.Any(cv.url, None), + vol.Optional("currency"): cv.currency, } ) async def websocket_update_config(hass, connection, msg): @@ -89,4 +90,7 @@ async def websocket_detect_config(hass, connection, msg): if location_info.time_zone: info["time_zone"] = location_info.time_zone + if location_info.currency: + info["currency"] = location_info.currency + connection.send_result(msg["id"], info) diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index dd1bf1f08e2..b0f6fca4817 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -61,7 +61,7 @@ class ZWaveLogView(HomeAssistantView): def _get_log(self, hass, lines): """Retrieve the logfile content.""" logfilepath = hass.config.path(OZW_LOG_FILENAME) - with open(logfilepath) as logfile: + with open(logfilepath, encoding="utf8") as logfile: data = (line.rstrip() for line in logfile) if lines == 0: loglines = list(data) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index e988e58f76b..f06ec330815 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -166,10 +166,10 @@ class Configurator: data.update( { key: value - for key, value in [ + for key, value in ( (ATTR_DESCRIPTION, description), (ATTR_SUBMIT_CAPTION, submit_caption), - ] + ) if value is not None } ) diff --git a/homeassistant/components/configurator/translations/he.json b/homeassistant/components/configurator/translations/he.json index 7cc7aad41d7..aeff95ca5ce 100644 --- a/homeassistant/components/configurator/translations/he.json +++ b/homeassistant/components/configurator/translations/he.json @@ -1,9 +1,9 @@ { "state": { "_": { - "configure": "\u05d4\u05d2\u05d3\u05e8", - "configured": "\u05d4\u05d5\u05d2\u05d3\u05e8" + "configure": "\u05d4\u05d2\u05d3\u05e8\u05d4", + "configured": "\u05de\u05d5\u05d2\u05d3\u05e8" } }, - "title": "\u05e7\u05d5\u05e0\u05e4\u05d9\u05d2\u05d5\u05e8\u05d8\u05d5\u05e8" + "title": "\u05e7\u05d5\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 6e4af61e24b..b7806e665f3 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -1,4 +1,6 @@ """The Control4 integration.""" +from __future__ import annotations + import json import logging @@ -149,9 +151,9 @@ class Control4Entity(CoordinatorEntity): coordinator: DataUpdateCoordinator, name: str, idx: int, - device_name: str, - device_manufacturer: str, - device_model: str, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, device_id: int, ) -> None: """Initialize a Control4 entity.""" @@ -174,7 +176,7 @@ class Control4Entity(CoordinatorEntity): @property def unique_id(self) -> str: """Return a unique ID.""" - return self._idx + return str(self._idx) @property def device_info(self): diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py index 46fc35398fe..38eca233f27 100644 --- a/homeassistant/components/control4/light.py +++ b/homeassistant/components/control4/light.py @@ -1,4 +1,6 @@ """Platform for Control4 Lights.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -145,9 +147,9 @@ class Control4Light(Control4Entity, LightEntity): coordinator: DataUpdateCoordinator, name: str, idx: int, - device_name: str, - device_manufacturer: str, - device_model: str, + device_name: str | None, + device_manufacturer: str | None, + device_model: str | None, device_id: int, is_dimmer: bool, ) -> None: diff --git a/homeassistant/components/control4/translations/de.json b/homeassistant/components/control4/translations/de.json index e50e2499320..4c9ef9abf11 100644 --- a/homeassistant/components/control4/translations/de.json +++ b/homeassistant/components/control4/translations/de.json @@ -11,7 +11,7 @@ "step": { "user": { "data": { - "host": "IP-Addresse", + "host": "IP-Adresse", "password": "Passwort", "username": "Benutzername" }, diff --git a/homeassistant/components/conversation/translations/he.json b/homeassistant/components/conversation/translations/he.json index eeccec319af..63cfb10abe8 100644 --- a/homeassistant/components/conversation/translations/he.json +++ b/homeassistant/components/conversation/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05e9\u05c2\u05b4\u05d9\u05d7\u05b8\u05d4" + "title": "\u05e9\u05d9\u05d7\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/coolmaster/translations/hu.json b/homeassistant/components/coolmaster/translations/hu.json index cf688d6fdeb..bf67763ca6b 100644 --- a/homeassistant/components/coolmaster/translations/hu.json +++ b/homeassistant/components/coolmaster/translations/hu.json @@ -6,7 +6,8 @@ "step": { "user": { "data": { - "host": "Hoszt" + "host": "Hoszt", + "off": "Ki lehet kapcsolni" } } } diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json index 08a88d1b826..87410d8b572 100644 --- a/homeassistant/components/coronavirus/manifest.json +++ b/homeassistant/components/coronavirus/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coronavirus", "requirements": ["coronavirus==1.1.1"], - "codeowners": ["@home_assistant/core"], + "codeowners": ["@home-assistant/core"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/coronavirus/translations/de.json b/homeassistant/components/coronavirus/translations/de.json index 45eaff64200..24da7b952ea 100644 --- a/homeassistant/components/coronavirus/translations/de.json +++ b/homeassistant/components/coronavirus/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Land ist bereits konfiguriert.", + "already_configured": "Der Dienst ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "step": { @@ -9,7 +9,7 @@ "data": { "country": "Land" }, - "title": "W\u00e4hlen Sie ein Land aus, das \u00fcberwacht werden soll" + "title": "W\u00e4hle ein Land aus, das \u00fcberwacht werden soll" } } } diff --git a/homeassistant/components/coronavirus/translations/fr.json b/homeassistant/components/coronavirus/translations/fr.json index 21a72d80f61..9a9a960cf31 100644 --- a/homeassistant/components/coronavirus/translations/fr.json +++ b/homeassistant/components/coronavirus/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Ce pays est d\u00e9j\u00e0 configur\u00e9.", + "cannot_connect": "\u00c9chec de connexion" }, "step": { "user": { diff --git a/homeassistant/components/coronavirus/translations/hu.json b/homeassistant/components/coronavirus/translations/hu.json index 631454ec045..9b79c82a014 100644 --- a/homeassistant/components/coronavirus/translations/hu.json +++ b/homeassistant/components/coronavirus/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem siker\u00fclt csatlakozni" }, "step": { "user": { diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 110dd09098e..00fef5c6485 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,6 +1,7 @@ """Support for Cover devices.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -30,7 +31,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass @@ -170,9 +171,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class CoverEntityDescription(EntityDescription): + """A class that describes cover entities.""" + + class CoverEntity(Entity): """Base class for cover entities.""" + entity_description: CoverEntityDescription _attr_current_cover_position: int | None = None _attr_current_cover_tilt_position: int | None = None _attr_is_closed: bool | None diff --git a/homeassistant/components/cover/translations/he.json b/homeassistant/components/cover/translations/he.json index fce73cc1698..5dad593467c 100644 --- a/homeassistant/components/cover/translations/he.json +++ b/homeassistant/components/cover/translations/he.json @@ -6,11 +6,11 @@ }, "state": { "_": { - "closed": "\u05e0\u05e1\u05d2\u05e8", + "closed": "\u05e1\u05d2\u05d5\u05e8", "closing": "\u05e1\u05d5\u05d2\u05e8", "open": "\u05e4\u05ea\u05d5\u05d7", "opening": "\u05e4\u05d5\u05ea\u05d7", - "stopped": "\u05e2\u05e6\u05d5\u05e8" + "stopped": "\u05e2\u05e6\u05e8" } }, "title": "\u05d5\u05d9\u05dc\u05d5\u05df" diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py index d17c3fc0d93..b3e833bb64f 100644 --- a/homeassistant/components/daikin/climate.py +++ b/homeassistant/components/daikin/climate.py @@ -135,7 +135,7 @@ class DaikinClimate(ClimateEntity): """Set device settings using API.""" values = {} - for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE]: + for attr in (ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE, ATTR_HVAC_MODE): value = settings.get(attr) if value is None: continue diff --git a/homeassistant/components/daikin/translations/de.json b/homeassistant/components/daikin/translations/de.json index dcec53c1569..038a997201a 100644 --- a/homeassistant/components/daikin/translations/de.json +++ b/homeassistant/components/daikin/translations/de.json @@ -16,7 +16,7 @@ "host": "Host", "password": "Passwort" }, - "description": "Gib die IP-Adresse deiner Daikin AC ein.", + "description": "Gib die IP-Adresse deiner Daikin AC ein.\n\nBeachte, dass API-Schl\u00fcssel und Passwort nur von BRP072Cxx bzw. SKYFi-Ger\u00e4ten verwendet werden.", "title": "Daikin AC konfigurieren" } } diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index b82f544329c..a67d7181a90 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -2,7 +2,7 @@ "domain": "debugpy", "name": "Remote Python Debugger", "documentation": "https://www.home-assistant.io/integrations/debugpy", - "requirements": ["debugpy==1.3.0"], + "requirements": ["debugpy==1.4.0"], "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index 872dc3688c2..c5336825878 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -163,11 +163,13 @@ class DeconzAlarmEvent(DeconzEvent): if ( self.gateway.ignore_state_updates or "action" not in self._device.changed_keys - or self._device.action == "" ): return - state, code, _area = self._device.action.split(",") + try: + state, code, _area = self._device.action.split(",") + except (AttributeError, ValueError): + return if state not in DECONZ_TO_ALARM_STATE: return diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index d7e42808851..5beaba2c5a5 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -450,6 +450,20 @@ GIRA_JUNG_SWITCH = { (CONF_SHORT_RELEASE, CONF_BUTTON_8): {CONF_EVENT: 8002}, } +LEGRAND_ZGP_TOGGLE_SWITCH_MODEL = "LEGRANDZGPTOGGLESWITCH" +LEGRAND_ZGP_TOGGLE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): {CONF_EVENT: 1002}, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): {CONF_EVENT: 1004}, +} + +LEGRAND_ZGP_SCENE_SWITCH_MODEL = "LEGRANDZGPSCENESWITCH" +LEGRAND_ZGP_SCENE_SWITCH = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): {CONF_EVENT: 1002}, + (CONF_SHORT_PRESS, CONF_BUTTON_2): {CONF_EVENT: 2002}, + (CONF_SHORT_PRESS, CONF_BUTTON_3): {CONF_EVENT: 3002}, + (CONF_SHORT_PRESS, CONF_BUTTON_4): {CONF_EVENT: 4002}, +} + LIDL_SILVERCREST_DOORBELL_MODEL = "HG06668" LIDL_SILVERCREST_DOORBELL = { (CONF_SHORT_PRESS, ""): {CONF_EVENT: 1002}, @@ -566,9 +580,11 @@ REMOTES = { AQARA_OPPLE_6_BUTTONS_MODEL: AQARA_OPPLE_6_BUTTONS, DRESDEN_ELEKTRONIK_LIGHTING_SWITCH_MODEL: DRESDEN_ELEKTRONIK_LIGHTING_SWITCH, DRESDEN_ELEKTRONIK_SCENE_SWITCH_MODEL: DRESDEN_ELEKTRONIK_SCENE_SWITCH, - GIRA_JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, - GIRA_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, - JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH_MODEL, + GIRA_JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH, + GIRA_SWITCH_MODEL: GIRA_JUNG_SWITCH, + JUNG_SWITCH_MODEL: GIRA_JUNG_SWITCH, + LEGRAND_ZGP_TOGGLE_SWITCH_MODEL: LEGRAND_ZGP_TOGGLE_SWITCH, + LEGRAND_ZGP_SCENE_SWITCH_MODEL: LEGRAND_ZGP_SCENE_SWITCH, LIDL_SILVERCREST_DOORBELL_MODEL: LIDL_SILVERCREST_DOORBELL, LIGHTIFIY_FOUR_BUTTON_REMOTE_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, LIGHTIFIY_FOUR_BUTTON_REMOTE_4X_MODEL: LIGHTIFIY_FOUR_BUTTON_REMOTE, diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index e0f12303946..9282f2d26cc 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, @@ -40,6 +41,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.util import dt as dt_util from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice @@ -51,6 +53,7 @@ ATTR_DAYLIGHT = "daylight" ATTR_EVENT_ID = "event_id" DEVICE_CLASS = { + Consumption: DEVICE_CLASS_ENERGY, Humidity: DEVICE_CLASS_HUMIDITY, LightLevel: DEVICE_CLASS_ILLUMINANCE, Power: DEVICE_CLASS_POWER, @@ -65,6 +68,7 @@ ICON = { } STATE_CLASS = { + Consumption: STATE_CLASS_MEASUREMENT, Humidity: STATE_CLASS_MEASUREMENT, Pressure: STATE_CLASS_MEASUREMENT, Temperature: STATE_CLASS_MEASUREMENT, @@ -158,6 +162,9 @@ class DeconzSensor(DeconzDevice, SensorEntity): self._attr_state_class = STATE_CLASS.get(type(self._device)) self._attr_unit_of_measurement = UNIT_OF_MEASUREMENT.get(type(self._device)) + if device.type in Consumption.ZHATYPE: + self._attr_last_reset = dt_util.utc_from_timestamp(0) + @callback def async_update_callback(self, force_update=False): """Update the sensor's state.""" diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index a4f4aec6a76..08ee9e11561 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -153,7 +153,7 @@ async def async_refresh_devices_service(gateway): await gateway.api.refresh_state() gateway.ignore_state_updates = False - for new_device_type in [NEW_GROUP, NEW_LIGHT, NEW_SCENE, NEW_SENSOR]: + for new_device_type in (NEW_GROUP, NEW_LIGHT, NEW_SCENE, NEW_SENSOR): gateway.async_add_device_callback(new_device_type, force=True) diff --git a/homeassistant/components/deconz/translations/ar.json b/homeassistant/components/deconz/translations/ar.json new file mode 100644 index 00000000000..9624f9c47c9 --- /dev/null +++ b/homeassistant/components/deconz/translations/ar.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u0627\u0644\u062c\u0633\u0631 \u062a\u0645 \u062a\u0643\u0648\u064a\u0646\u0647 \u0645\u0633\u0628\u0642\u0627", + "no_bridges": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641 \u062c\u0633\u0648\u0631 deCONZ" + }, + "error": { + "no_key": "\u062a\u0639\u0630\u0631 \u0627\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u0649 \u0645\u0641\u062a\u0627\u062d API" + }, + "step": { + "link": { + "description": "\u0627\u0641\u062a\u062d \u0628\u0648\u0627\u0628\u0629 deCONZ \u0644\u0644\u062a\u0633\u062c\u064a\u0644 \u0641\u064a Home Assistant. \n\n 1. \u0627\u0646\u062a\u0642\u0644 \u0625\u0644\u0649 \u0625\u0639\u062f\u0627\u062f\u0627\u062a deCONZ - > \u0627\u0644\u0628\u0648\u0627\u0628\u0629 - > \u062e\u064a\u0627\u0631\u0627\u062a \u0645\u062a\u0642\u062f\u0645\u0629\n 2. \u0627\u0636\u063a\u0637 \u0639\u0644\u0649 \u0632\u0631 \"\u0645\u0635\u0627\u062f\u0642\u0629 \u0627\u0644\u062a\u0637\u0628\u064a\u0642\"", + "title": "\u0627\u0644\u0627\u0631\u062a\u0628\u0627\u0637 \u0645\u0639 deCONZ" + } + } + }, + "device_automation": { + "trigger_type": { + "remote_flip_180_degrees": "\u0627\u0646\u0642\u0644\u0628 \u0627\u0644\u062c\u0647\u0627\u0632 180 \u062f\u0631\u062c\u0629", + "remote_flip_90_degrees": "\u0627\u0646\u0642\u0644\u0628 \u0627\u0644\u062c\u0647\u0627\u0632 90 \u062f\u0631\u062c\u0629" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/translations/hu.json b/homeassistant/components/deconz/translations/hu.json index 0463463c0b3..84493ccb9f6 100644 --- a/homeassistant/components/deconz/translations/hu.json +++ b/homeassistant/components/deconz/translations/hu.json @@ -14,6 +14,7 @@ "flow_title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 ({host})", "step": { "hassio_confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant-ot, hogy csatlakozzon a (z) {addon} \u00e1ltal biztos\u00edtott deCONZ \u00e1tj\u00e1r\u00f3hoz?", "title": "deCONZ Zigbee \u00e1tj\u00e1r\u00f3 a Supervisor kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" }, "link": { @@ -89,7 +90,8 @@ "deconz_devices": { "data": { "allow_clip_sensor": "Enged\u00e9lyezze a deCONZ CLIP \u00e9rz\u00e9kel\u0151ket", - "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se" + "allow_deconz_groups": "DeCONZ f\u00e9nycsoportok enged\u00e9lyez\u00e9se", + "allow_new_devices": "Enged\u00e9lyezze az \u00faj eszk\u00f6z\u00f6k automatikus hozz\u00e1ad\u00e1s\u00e1t" }, "description": "A deCONZ eszk\u00f6zt\u00edpusok l\u00e1that\u00f3s\u00e1g\u00e1nak konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index 45c42c4bb1c..2564ff0cd9e 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -8,6 +8,7 @@ from bluepy.btle import BTLEException # pylint: disable=import-error import decora # pylint: disable=import-error import voluptuous as vol +from homeassistant import util from homeassistant.components.light import ( ATTR_BRIGHTNESS, PLATFORM_SCHEMA, @@ -16,7 +17,6 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME import homeassistant.helpers.config_validation as cv -import homeassistant.util as util _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 032a6845340..834438f5a9f 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -7,6 +7,7 @@ "cloud", "counter", "dhcp", + "energy", "frontend", "history", "input_boolean", diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index acd98465207..db53c1c528f 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -2,7 +2,11 @@ import asyncio from homeassistant import bootstrap, config_entries -from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + ATTR_ENTITY_ID, + EVENT_HOMEASSISTANT_START, + SOUND_PRESSURE_DB, +) import homeassistant.core as ha DOMAIN = "demo" @@ -22,6 +26,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "number", "select", "sensor", + "siren", "switch", "vacuum", "water_heater", @@ -118,7 +123,7 @@ async def async_setup(hass, config): "min": 0, "max": 10, "name": "Allowed Noise", - "unit_of_measurement": "dB", + "unit_of_measurement": SOUND_PRESSURE_DB, } } }, diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index cafc0e3f748..7eabf9bea2d 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -1,6 +1,14 @@ """Demo lock platform that has two fake locks.""" +import asyncio + from homeassistant.components.lock import SUPPORT_OPEN, LockEntity -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -9,6 +17,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DemoLock("Front Door", STATE_LOCKED), DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), DemoLock("Openable Lock", STATE_LOCKED, True), ] ) @@ -24,24 +33,67 @@ class DemoLock(LockEntity): _attr_should_poll = False - def __init__(self, name: str, state: str, openable: bool = False) -> None: + def __init__( + self, + name: str, + state: str, + openable: bool = False, + jam_on_operation: bool = False, + ) -> None: """Initialize the lock.""" self._attr_name = name - self._attr_is_locked = state == STATE_LOCKED if openable: self._attr_supported_features = SUPPORT_OPEN + self._state = state + self._openable = openable + self._jam_on_operation = jam_on_operation - def lock(self, **kwargs): + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + async def async_lock(self, **kwargs): """Lock the device.""" - self._attr_is_locked = True - self.schedule_update_ha_state() + self._state = STATE_LOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + if self._jam_on_operation: + self._state = STATE_JAMMED + else: + self._state = STATE_LOCKED + self.async_write_ha_state() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKING + self.async_write_ha_state() + await asyncio.sleep(2) + self._state = STATE_UNLOCKED + self.async_write_ha_state() - def open(self, **kwargs): + async def async_open(self, **kwargs): """Open the door latch.""" - self._attr_is_locked = False - self.schedule_update_ha_state() + self._state = STATE_UNLOCKED + self.async_write_ha_state() + + @property + def supported_features(self): + """Flag supported features.""" + if self._openable: + return SUPPORT_OPEN diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index 1badd391575..c8e54aa65f3 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -53,6 +53,7 @@ class DemoRemote(RemoteEntity): """Return device state attributes.""" if self._last_command_sent is not None: return {"last_command_sent": self._last_command_sent} + return None def turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index 817cbd435d1..488c34be983 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -10,9 +10,13 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -67,6 +71,24 @@ async def async_setup_platform( CONCENTRATION_PARTS_PER_MILLION, 14, ), + DemoSensor( + "sensor_5", + "Power consumption", + 100, + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + POWER_WATT, + None, + ), + DemoSensor( + "sensor_6", + "Today energy", + 15, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + None, + ), ] ) diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py new file mode 100644 index 00000000000..b810e48f954 --- /dev/null +++ b/homeassistant/components/demo/siren.py @@ -0,0 +1,83 @@ +"""Demo platform that offers a fake siren device.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.siren import SirenEntity +from homeassistant.components.siren.const import ( + SUPPORT_DURATION, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +SUPPORT_FLAGS = SUPPORT_TURN_OFF | SUPPORT_TURN_ON + + +async def async_setup_platform( + hass: HomeAssistant, + config: Config, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType = None, +) -> None: + """Set up the Demo siren devices.""" + async_add_entities( + [ + DemoSiren(name="Siren"), + DemoSiren( + name="Siren with all features", + available_tones=["fire", "alarm"], + support_volume_set=True, + support_duration=True, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo siren devices config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoSiren(SirenEntity): + """Representation of a demo siren device.""" + + def __init__( + self, + name: str, + available_tones: str | None = None, + support_volume_set: bool = False, + support_duration: bool = False, + is_on: bool = True, + ) -> None: + """Initialize the siren device.""" + self._attr_name = name + self._attr_should_poll = False + self._attr_supported_features = SUPPORT_FLAGS + self._attr_is_on = is_on + if available_tones is not None: + self._attr_supported_features |= SUPPORT_TONES + if support_volume_set: + self._attr_supported_features |= SUPPORT_VOLUME_SET + if support_duration: + self._attr_supported_features |= SUPPORT_DURATION + self._attr_available_tones = available_tones + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the siren on.""" + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the siren off.""" + self._attr_is_on = False + self.async_write_ha_state() diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 13853959a12..84554bf0db1 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -49,7 +49,6 @@ class DemoSwitch(SwitchEntity): self._attr_icon = icon self._attr_is_on = state self._attr_name = name or DEVICE_DEFAULT_NAME - self._attr_today_energy_kwh = 15 self._attr_unique_id = unique_id @property @@ -63,11 +62,9 @@ class DemoSwitch(SwitchEntity): def turn_on(self, **kwargs): """Turn the switch on.""" self._attr_is_on = True - self._attr_current_power_w = 100 self.schedule_update_ha_state() def turn_off(self, **kwargs): """Turn the device off.""" self._attr_is_on = False - self._attr_current_power_w = 0 self.schedule_update_ha_state() diff --git a/homeassistant/components/demo/translations/de.json b/homeassistant/components/demo/translations/de.json index 8d737e5e4c9..74178521138 100644 --- a/homeassistant/components/demo/translations/de.json +++ b/homeassistant/components/demo/translations/de.json @@ -17,7 +17,7 @@ "options_2": { "data": { "multi": "Mehrfachauswahl", - "select": "W\u00e4hlen Sie eine Option", + "select": "W\u00e4hle eine Option", "string": "String-Wert" } } diff --git a/homeassistant/components/demo/translations/select.ar.json b/homeassistant/components/demo/translations/select.ar.json new file mode 100644 index 00000000000..c151c29acfb --- /dev/null +++ b/homeassistant/components/demo/translations/select.ar.json @@ -0,0 +1,7 @@ +{ + "state": { + "demo__speed": { + "light_speed": "\u0633\u0631\u0639\u0629 \u0627\u0644\u0636\u0648\u0621" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.fr.json b/homeassistant/components/demo/translations/select.fr.json new file mode 100644 index 00000000000..d2b214e4078 --- /dev/null +++ b/homeassistant/components/demo/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Vitesse de la lumi\u00e8re", + "ludicrous_speed": "Vitesse ridicule", + "ridiculous_speed": "Vitesse ridicule" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/translations/select.nl.json b/homeassistant/components/demo/translations/select.nl.json new file mode 100644 index 00000000000..4312d8c4d34 --- /dev/null +++ b/homeassistant/components/demo/translations/select.nl.json @@ -0,0 +1,9 @@ +{ + "state": { + "demo__speed": { + "light_speed": "Lichtsnelheid", + "ludicrous_speed": "Lachwekkende snelheid", + "ridiculous_speed": "Belachelijke snelheid" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/ar.json b/homeassistant/components/denonavr/translations/ar.json new file mode 100644 index 00000000000..18369967b90 --- /dev/null +++ b/homeassistant/components/denonavr/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u0641\u0634\u0644 \u0627\u0644\u0627\u062a\u0635\u0627\u0644\u060c \u0627\u0644\u0631\u062c\u0627\u0621 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649\u060c \u0642\u062f \u064a\u0633\u0627\u0639\u062f \u0642\u0637\u0639 \u0627\u0644\u062a\u064a\u0627\u0631 \u0627\u0644\u0643\u0647\u0631\u0628\u0627\u0626\u064a \u0648\u0643\u0627\u0628\u0644\u0627\u062a \u0625\u064a\u062b\u0631\u0646\u062a \u0648\u0625\u0639\u0627\u062f\u0629 \u062a\u0648\u0635\u064a\u0644\u0647\u0627" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/denonavr/translations/de.json b/homeassistant/components/denonavr/translations/de.json index f40c665489c..300131280ac 100644 --- a/homeassistant/components/denonavr/translations/de.json +++ b/homeassistant/components/denonavr/translations/de.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", - "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuchen Sie es noch einmal. Trennen Sie ggf. Strom- und Ethernetkabel und verbinden Sie diese erneut.", + "cannot_connect": "Verbindung fehlgeschlagen. Bitte versuche es noch einmal. Trenne ggf. Strom- und Ethernetkabel und verbinde diese erneut.", "not_denonavr_manufacturer": "Kein Denon AVR-Netzwerkempf\u00e4nger, entdeckter Hersteller stimmte nicht \u00fcberein", "not_denonavr_missing": "Kein Denon AVR-Netzwerk-Receiver, Erkennungsinformationen nicht vollst\u00e4ndig" }, diff --git a/homeassistant/components/denonavr/translations/hu.json b/homeassistant/components/denonavr/translations/hu.json index 2ae4bc69b55..e6727d3c29f 100644 --- a/homeassistant/components/denonavr/translations/hu.json +++ b/homeassistant/components/denonavr/translations/hu.json @@ -16,5 +16,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "update_audyssey": "Friss\u00edtse az Audyssey be\u00e1ll\u00edt\u00e1sait" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 333549e82e0..991a4bb7bb1 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -898,7 +898,7 @@ async def async_load_config( def update_config(path: str, dev_id: str, device: Device) -> None: """Add device to YAML configuration file.""" - with open(path, "a") as out: + with open(path, "a", encoding="utf8") as out: device_config = { device.dev_id: { ATTR_NAME: device.name, diff --git a/homeassistant/components/device_tracker/translations/he.json b/homeassistant/components/device_tracker/translations/he.json index 5db22ed4071..2f3ccc1ec1e 100644 --- a/homeassistant/components/device_tracker/translations/he.json +++ b/homeassistant/components/device_tracker/translations/he.json @@ -5,5 +5,5 @@ "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" } }, - "title": "\u05de\u05e2\u05e7\u05d1 \u05de\u05db\u05e9\u05d9\u05e8" + "title": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 4c8757e4eff..46b8f9dcaea 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -1,6 +1,10 @@ """The devolo_home_control integration.""" +from __future__ import annotations + import asyncio from functools import partial +from types import MappingProxyType +from typing import Any from devolo_home_control_api.exceptions.gateway import GatewayOfflineError from devolo_home_control_api.homecontrol import HomeControl @@ -9,7 +13,7 @@ from devolo_home_control_api.mydevolo import Mydevolo from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import ( @@ -37,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) - if GATEWAY_SERIAL_PATTERN.match(entry.unique_id): + if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id): uuid = await hass.async_add_executor_job(mydevolo.uuid) hass.config_entries.async_update_entry(entry, unique_id=uuid) @@ -60,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - def shutdown(event): + def shutdown(event: Event) -> None: for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: gateway.websocket_disconnect( f"websocket disconnect requested by {EVENT_HOMEASSISTANT_STOP}" @@ -78,17 +82,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) await asyncio.gather( - *[ + *( hass.async_add_executor_job(gateway.websocket_disconnect) for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"] - ] + ) ) hass.data[DOMAIN][entry.entry_id]["listener"]() hass.data[DOMAIN].pop(entry.entry_id) return unload -def configure_mydevolo(conf: dict) -> Mydevolo: +def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: """Configure mydevolo.""" mydevolo = Mydevolo() mydevolo.user = conf[CONF_USERNAME] diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index e99c96832ae..c19d74b4c33 100644 --- a/homeassistant/components/devolo_home_control/binary_sensor.py +++ b/homeassistant/components/devolo_home_control/binary_sensor.py @@ -1,4 +1,9 @@ """Platform for binary sensor integration.""" +from __future__ import annotations + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_DOOR, DEVICE_CLASS_HEAT, @@ -11,6 +16,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_device import DevoloDeviceEntity @@ -26,10 +32,10 @@ DEVICE_CLASS_MAPPING = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all binary sensor and multi level sensor devices and setup them via config entry.""" - entities = [] + entities: list[BinarySensorEntity] = [] for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.binary_sensor_devices: @@ -61,7 +67,9 @@ async def async_setup_entry( class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): """Representation of a binary sensor within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo binary sensor.""" self._binary_sensor_property = device_instance.binary_sensor_property.get( element_uid @@ -73,38 +81,39 @@ class DevoloBinaryDeviceEntity(DevoloDeviceEntity, BinarySensorEntity): element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get( + self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._binary_sensor_property.sub_type or self._binary_sensor_property.sensor_type ) - if self._device_class is None: + if self._attr_device_class is None: if device_instance.binary_sensor_property.get(element_uid).sub_type != "": - self._name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" + self._attr_name += f" {device_instance.binary_sensor_property.get(element_uid).sub_type}" else: - self._name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" + self._attr_name += f" {device_instance.binary_sensor_property.get(element_uid).sensor_type}" self._value = self._binary_sensor_property.state if element_uid.startswith("devolo.WarningBinaryFI:"): - self._device_class = DEVICE_CLASS_PROBLEM - self._enabled_default = False + self._attr_device_class = DEVICE_CLASS_PROBLEM + self._attr_entity_registry_enabled_default = False @property - def is_on(self): + def is_on(self) -> bool: """Return the state.""" - return self._value - - @property - def device_class(self): - """Return device class.""" - return self._device_class + return bool(self._value) class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity): """Representation of a remote control within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid, key): + def __init__( + self, + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + key: int, + ) -> None: """Initialize a devolo remote control.""" self._remote_control_property = device_instance.remote_control_property.get( element_uid @@ -117,24 +126,19 @@ class DevoloRemoteControl(DevoloDeviceEntity, BinarySensorEntity): ) self._key = key - self._state = False + self._attr_is_on = False - @property - def is_on(self): - """Return the state.""" - return self._state - - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the binary sensor state.""" if ( message[0] == self._remote_control_property.element_uid and message[1] == self._key ): - self._state = True + self._attr_is_on = True elif ( message[0] == self._remote_control_property.element_uid and message[1] == 0 ): - self._state = False + self._attr_is_on = False else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 018c9cf36ec..ff4d8a01198 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -1,6 +1,11 @@ """Platform for climate integration.""" from __future__ import annotations +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.climate import ( ATTR_TEMPERATURE, HVAC_MODE_HEAT, @@ -11,13 +16,14 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all cover devices and setup them via config entry.""" entities = [] @@ -44,6 +50,25 @@ async def async_setup_entry( class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntity): """Representation of a climate/thermostat device within devolo Home Control.""" + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a climate entity within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._attr_hvac_mode = HVAC_MODE_HEAT + self._attr_hvac_modes = [HVAC_MODE_HEAT] + self._attr_min_temp = self._multi_level_switch_property.min + self._attr_max_temp = self._multi_level_switch_property.max + self._attr_precision = PRECISION_TENTHS + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_target_temperature_step = PRECISION_HALVES + self._attr_temperature_unit = TEMP_CELSIUS + @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -64,49 +89,9 @@ class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntit """Return the target temperature.""" return self._value - @property - def target_temperature_step(self) -> float: - """Return the precision of the target temperature.""" - return PRECISION_HALVES - - @property - def hvac_mode(self) -> str: - """Return the supported HVAC mode.""" - return HVAC_MODE_HEAT - - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return [HVAC_MODE_HEAT] - - @property - def min_temp(self) -> float: - """Return the minimum set temperature value.""" - return self._multi_level_switch_property.min - - @property - def max_temp(self) -> float: - """Return the maximum set temperature value.""" - return self._multi_level_switch_property.max - - @property - def precision(self) -> float: - """Return the precision of the set temperature.""" - return PRECISION_TENTHS - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def temperature_unit(self) -> str: - """Return the supported unit of temperature.""" - return TEMP_CELSIUS - def set_hvac_mode(self, hvac_mode: str) -> None: """Do nothing as devolo devices do not support changing the hvac mode.""" - def set_temperature(self, **kwargs): + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" self._multi_level_switch_property.set(kwargs[ATTR_TEMPERATURE]) diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index 10172b94452..e6b3dcbe329 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,9 +1,15 @@ """Config flow to configure the devolo home control integration.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.typing import DiscoveryInfoType from . import configure_mydevolo @@ -16,16 +22,18 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize devolo Home Control flow.""" self.data_schema = { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - self._reauth_entry = None + self._reauth_entry: ConfigEntry | None = None self._url = DEFAULT_MYDEVOLO - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if self.show_advanced_options: self.data_schema[vol.Required(CONF_MYDEVOLO, default=self._url)] = str @@ -36,7 +44,9 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except CredentialsInvalid: return self._show_form(step_id="user", errors={"base": "invalid_auth"}) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle zeroconf discovery.""" # Check if it is a gateway if discovery_info.get("properties", {}).get("MT") in SUPPORTED_MODEL_TYPES: @@ -44,7 +54,9 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_zeroconf_confirm() return self.async_abort(reason="Not a devolo Home Control gateway.") - async def async_step_zeroconf_confirm(self, user_input=None): + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by zeroconf.""" if user_input is None: return self._show_form(step_id="zeroconf_confirm") @@ -55,7 +67,7 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="zeroconf_confirm", errors={"base": "invalid_auth"} ) - async def async_step_reauth(self, user_input): + async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: """Handle reauthentication.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] @@ -67,7 +79,9 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by reauthentication.""" if user_input is None: return self._show_form(step_id="reauth_confirm") @@ -82,7 +96,7 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", errors={"base": "reauth_failed"} ) - async def _connect_mydevolo(self, user_input): + async def _connect_mydevolo(self, user_input: dict[str, Any]) -> FlowResult: """Connect to mydevolo.""" user_input[CONF_MYDEVOLO] = user_input.get(CONF_MYDEVOLO, self._url) mydevolo = configure_mydevolo(conf=user_input) @@ -118,7 +132,9 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") @callback - def _show_form(self, step_id, errors=None): + def _show_form( + self, step_id: str, errors: dict[str, str] | None = None + ) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id=step_id, diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index d552c53bbfc..7a1a93596d3 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -1,4 +1,11 @@ """Platform for cover integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.cover import ( DEVICE_CLASS_BLIND, SUPPORT_CLOSE, @@ -8,13 +15,14 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all cover devices and setup them via config entry.""" entities = [] @@ -37,34 +45,39 @@ async def async_setup_entry( class DevoloCoverDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, CoverEntity): """Representation of a cover device within devolo Home Control.""" + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a climate entity within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + + self._attr_device_class = DEVICE_CLASS_BLIND + self._attr_supported_features = ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + ) + @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position. 0 is closed. 100 is open.""" return self._value @property - def device_class(self): - """Return the class of the device.""" - return DEVICE_CLASS_BLIND - - @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the blind is closed or not.""" return not bool(self._value) - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION - - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the blind.""" self._multi_level_switch_property.set(100) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the blind.""" self._multi_level_switch_property.set(0) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Set the blind to the given position.""" self._multi_level_switch_property.set(kwargs["position"]) diff --git a/homeassistant/components/devolo_home_control/devolo_device.py b/homeassistant/components/devolo_home_control/devolo_device.py index 6aef842ffff..781799cbf37 100644 --- a/homeassistant/components/devolo_home_control/devolo_device.py +++ b/homeassistant/components/devolo_home_control/devolo_device.py @@ -1,6 +1,11 @@ """Base class for a device entity integrated in devolo Home Control.""" +from __future__ import annotations + import logging +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -12,31 +17,39 @@ _LOGGER = logging.getLogger(__name__) class DevoloDeviceEntity(Entity): """Abstract representation of a device within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo device entity.""" self._device_instance = device_instance - self._unique_id = element_uid self._homecontrol = homecontrol - self._name = device_instance.settings_property["general_device_settings"].name - self._area = device_instance.settings_property["general_device_settings"].zone - self._device_class = None - self._value = None - self._unit = None - self._enabled_default = True - # This is not doing I/O. It fetches an internal state of the API - self._available = device_instance.is_online() + self._attr_available = ( + device_instance.is_online() + ) # This is not doing I/O. It fetches an internal state of the API + self._attr_name: str = device_instance.settings_property[ + "general_device_settings" + ].name + self._attr_should_poll = False + self._attr_unique_id = element_uid + self._attr_device_info = { + "identifiers": {(DOMAIN, self._device_instance.uid)}, + "name": self._attr_name, + "manufacturer": device_instance.brand, + "model": device_instance.name, + "suggested_area": device_instance.settings_property[ + "general_device_settings" + ].zone, + } - # Get the brand and model information - self._brand = device_instance.brand - self._model = device_instance.name - - self.subscriber = None + self.subscriber: Subscriber | None = None self.sync_callback = self._sync + self._value: int + self._unit = "" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" - self.subscriber = Subscriber(self._name, callback=self.sync_callback) + self.subscriber = Subscriber(self._attr_name, callback=self.sync_callback) self._homecontrol.publisher.register( self._device_instance.uid, self.subscriber, self.sync_callback ) @@ -47,56 +60,20 @@ class DevoloDeviceEntity(Entity): self._device_instance.uid, self.subscriber ) - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return self._unique_id - - @property - def device_info(self): - """Return the device info.""" - return { - "identifiers": {(DOMAIN, self._device_instance.uid)}, - "name": self._name, - "manufacturer": self._brand, - "model": self._model, - "suggested_area": self._area, - } - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the display name of this entity.""" - return self._name - - @property - def available(self) -> bool: - """Return the online state.""" - return self._available - - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the state.""" - if message[0] == self._unique_id: + if message[0] == self._attr_unique_id: self._value = message[1] else: self._generic_message(message) self.schedule_update_ha_state() - def _generic_message(self, message): + def _generic_message(self, message: tuple) -> None: """Handle generic messages.""" if len(message) == 3 and message[2] == "battery_level": self._value = message[1] elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. - self._available = self._device_instance.is_online() + self._attr_available = self._device_instance.is_online() else: _LOGGER.debug("No valid message received: %s", message) diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py index 482edd51f1e..eafd1e63b1f 100644 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py @@ -1,11 +1,16 @@ """Base class for multi level switches in devolo Home Control.""" +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from .devolo_device import DevoloDeviceEntity class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a multi level switch within devolo Home Control.""" super().__init__( homecontrol=homecontrol, diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 7fd59bd7d11..28da95c8902 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -1,4 +1,11 @@ """Platform for light integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, @@ -6,13 +13,14 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all light devices and setup them via config entry.""" entities = [] @@ -35,7 +43,9 @@ async def async_setup_entry( class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): """Representation of a light within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a devolo multi level switch.""" super().__init__( homecontrol=homecontrol, @@ -43,26 +53,22 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): element_uid=element_uid, ) + self._attr_supported_features = SUPPORT_BRIGHTNESS self._binary_switch_property = device_instance.binary_switch_property.get( element_uid.replace("Dimmer", "BinarySwitch") ) @property - def brightness(self): + def brightness(self) -> int: """Return the brightness value of the light.""" return round(self._value / 100 * 255) @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the light.""" return bool(self._value) - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS - - def turn_on(self, **kwargs) -> None: + def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" if kwargs.get(ATTR_BRIGHTNESS) is not None: self._multi_level_switch_property.set( @@ -76,7 +82,7 @@ class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): # If there is no binary switch attached to the device, turn it on to 100 %. self._multi_level_switch_property.set(100) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" if self._binary_switch_property is not None: self._binary_switch_property.set(False) diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 5886c1d0fe2..9621a49157a 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -2,7 +2,7 @@ "domain": "devolo_home_control", "name": "devolo Home Control", "documentation": "https://www.home-assistant.io/integrations/devolo_home_control", - "requirements": ["devolo-home-control-api==0.17.3"], + "requirements": ["devolo-home-control-api==0.17.4"], "after_dependencies": ["zeroconf"], "config_flow": true, "codeowners": ["@2Fake", "@Shutgun"], diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index e3091305375..7cb8cc8e837 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -1,4 +1,9 @@ """Platform for sensor integration.""" +from __future__ import annotations + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + from homeassistant.components.sensor import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, @@ -7,11 +12,13 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, SensorEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .devolo_device import DevoloDeviceEntity @@ -28,10 +35,10 @@ DEVICE_CLASS_MAPPING = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all sensor devices and setup them via config entry.""" - entities = [] + entities: list[SensorEntity] = [] for gateway in hass.data[DOMAIN][entry.entry_id]["gateways"]: for device in gateway.multi_level_sensor_devices: @@ -46,7 +53,7 @@ async def async_setup_entry( for device in gateway.devices.values(): if hasattr(device, "consumption_property"): for consumption in device.consumption_property: - for consumption_type in ["current", "total"]: + for consumption_type in ("current", "total"): entities.append( DevoloConsumptionEntity( homecontrol=gateway, @@ -71,30 +78,20 @@ class DevoloMultiLevelDeviceEntity(DevoloDeviceEntity, SensorEntity): """Abstract representation of a multi level sensor within devolo Home Control.""" @property - def device_class(self) -> str: - """Return device class.""" - return self._device_class - - @property - def state(self): + def state(self) -> int: """Return the state of the sensor.""" return self._value - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit - class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): """Representation of a generic multi level sensor within devolo Home Control.""" def __init__( self, - homecontrol, - device_instance, - element_uid, - ): + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + ) -> None: """Initialize a devolo multi level sensor.""" self._multi_level_sensor_property = device_instance.multi_level_sensor_property[ element_uid @@ -106,24 +103,26 @@ class DevoloGenericMultiLevelDeviceEntity(DevoloMultiLevelDeviceEntity): element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get( + self._attr_device_class = DEVICE_CLASS_MAPPING.get( self._multi_level_sensor_property.sensor_type ) + self._attr_unit_of_measurement = self._multi_level_sensor_property.unit self._value = self._multi_level_sensor_property.value - self._unit = self._multi_level_sensor_property.unit - if self._device_class is None: - self._name += f" {self._multi_level_sensor_property.sensor_type}" + if self._attr_device_class is None: + self._attr_name += f" {self._multi_level_sensor_property.sensor_type}" if element_uid.startswith("devolo.VoltageMultiLevelSensor:"): - self._enabled_default = False + self._attr_entity_registry_enabled_default = False class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): """Representation of a battery entity within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize a battery sensor.""" super().__init__( @@ -132,16 +131,22 @@ class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): element_uid=element_uid, ) - self._device_class = DEVICE_CLASS_MAPPING.get("battery") + self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") + self._attr_unit_of_measurement = PERCENTAGE self._value = device_instance.battery_level - self._unit = PERCENTAGE class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): """Representation of a consumption entity within devolo Home Control.""" - def __init__(self, homecontrol, device_instance, element_uid, consumption): + def __init__( + self, + homecontrol: HomeControl, + device_instance: Zwave, + element_uid: str, + consumption: str, + ) -> None: """Initialize a devolo consumption sensor.""" super().__init__( @@ -151,29 +156,39 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): ) self._sensor_type = consumption - self._device_class = DEVICE_CLASS_MAPPING.get(consumption) + self._attr_device_class = DEVICE_CLASS_MAPPING.get(consumption) + self._attr_unit_of_measurement = getattr( + device_instance.consumption_property[element_uid], f"{consumption}_unit" + ) + + if consumption == "total": + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_last_reset = device_instance.consumption_property[ + element_uid + ].total_since self._value = getattr( device_instance.consumption_property[element_uid], consumption ) - self._unit = getattr( - device_instance.consumption_property[element_uid], f"{consumption}_unit" - ) - self._name += f" {consumption}" + self._attr_name += f" {consumption}" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of the entity.""" - return f"{self._unique_id}_{self._sensor_type}" + return f"{self._attr_unique_id}_{self._sensor_type}" - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the consumption sensor state.""" - if message[0] == self._unique_id: + if message[0] == self._attr_unique_id and message[2] != "total_since": self._value = getattr( - self._device_instance.consumption_property[self._unique_id], + self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, ) + elif message[0] == self._attr_unique_id and message[2] == "total_since": + self._attr_last_reset = self._device_instance.consumption_property[ + self._attr_unique_id + ].total_since else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/subscriber.py b/homeassistant/components/devolo_home_control/subscriber.py index d291e4b174f..9899aa3a587 100644 --- a/homeassistant/components/devolo_home_control/subscriber.py +++ b/homeassistant/components/devolo_home_control/subscriber.py @@ -1,6 +1,7 @@ """Subscriber for devolo home control API publisher.""" import logging +from typing import Callable _LOGGER = logging.getLogger(__name__) @@ -8,12 +9,12 @@ _LOGGER = logging.getLogger(__name__) class Subscriber: """Subscriber class for the publisher in mprm websocket class.""" - def __init__(self, name, callback): + def __init__(self, name: str, callback: Callable) -> None: """Initiate the subscriber.""" self.name = name self.callback = callback - def update(self, message): + def update(self, message: str) -> None: """Trigger hass to update the device.""" _LOGGER.debug('%s got message "%s"', self.name, message) self.callback(message) diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 2a96198826b..4896d66b805 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -1,14 +1,22 @@ """Platform for switch integration.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl + 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 .const import DOMAIN from .devolo_device import DevoloDeviceEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Get all devices and setup the switch devices via config entry.""" entities = [] @@ -33,7 +41,9 @@ async def async_setup_entry( class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): """Representation of a switch.""" - def __init__(self, homecontrol, device_instance, element_uid): + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: """Initialize an devolo Switch.""" super().__init__( homecontrol=homecontrol, @@ -41,45 +51,24 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): element_uid=element_uid, ) self._binary_switch_property = self._device_instance.binary_switch_property.get( - self._unique_id + self._attr_unique_id ) - self._is_on = self._binary_switch_property.state + self._attr_is_on = self._binary_switch_property.state - if hasattr(self._device_instance, "consumption_property"): - self._consumption = self._device_instance.consumption_property.get( - self._unique_id.replace("BinarySwitch", "Meter") - ).current - else: - self._consumption = None - - @property - def is_on(self): - """Return the state.""" - return self._is_on - - @property - def current_power_w(self): - """Return the current consumption.""" - return self._consumption - - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Switch on the device.""" - self._is_on = True self._binary_switch_property.set(state=True) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Switch off the device.""" - self._is_on = False self._binary_switch_property.set(state=False) - def _sync(self, message): + def _sync(self, message: tuple) -> None: """Update the binary switch state and consumption.""" if message[0].startswith("devolo.BinarySwitch"): - self._is_on = self._device_instance.binary_switch_property[message[0]].state - elif message[0].startswith("devolo.Meter"): - self._consumption = self._device_instance.consumption_property[ + self._attr_is_on = self._device_instance.binary_switch_property[ message[0] - ].current + ].state else: self._generic_message(message) self.schedule_update_ha_state() diff --git a/homeassistant/components/devolo_home_control/translations/ca.json b/homeassistant/components/devolo_home_control/translations/ca.json index a41c1f78c15..968624e15c8 100644 --- a/homeassistant/components/devolo_home_control/translations/ca.json +++ b/homeassistant/components/devolo_home_control/translations/ca.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "El compte ja ha estat configurat" + "already_configured": "El compte ja ha estat configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "reauth_failed": "Utilitza el mateix usuari de mydevolo que abans." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/cs.json b/homeassistant/components/devolo_home_control/translations/cs.json index 44906c51207..54169346968 100644 --- a/homeassistant/components/devolo_home_control/translations/cs.json +++ b/homeassistant/components/devolo_home_control/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "\u00da\u010det je ji\u017e nastaven", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" diff --git a/homeassistant/components/devolo_home_control/translations/es.json b/homeassistant/components/devolo_home_control/translations/es.json index fe862c1c01d..b4a7a873aaa 100644 --- a/homeassistant/components/devolo_home_control/translations/es.json +++ b/homeassistant/components/devolo_home_control/translations/es.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "La cuenta ya ha sido configurada" + "already_configured": "La cuenta ya ha sido configurada", + "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "reauth_failed": "Por favor, utiliza el mismo usuario de mydevolo que antes." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/fr.json b/homeassistant/components/devolo_home_control/translations/fr.json index d0f806c042e..bc9a5715238 100644 --- a/homeassistant/components/devolo_home_control/translations/fr.json +++ b/homeassistant/components/devolo_home_control/translations/fr.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e." + "already_configured": "Cette unit\u00e9 centrale Home Control est d\u00e9j\u00e0 utilis\u00e9e.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { - "invalid_auth": "Authentification invalide" + "invalid_auth": "Authentification invalide", + "reauth_failed": "Veuillez utiliser le m\u00eame utilisateur mydevolo que pr\u00e9c\u00e9demment." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/he.json b/homeassistant/components/devolo_home_control/translations/he.json index 903818bf429..2ac09df14fd 100644 --- a/homeassistant/components/devolo_home_control/translations/he.json +++ b/homeassistant/components/devolo_home_control/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" @@ -10,7 +11,7 @@ "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05d3\u05d5\u05d0\u05e8 \u05d0\u05dc\u05e7\u05d8\u05e8\u05d5\u05e0\u05d9/ \u05de\u05d6\u05d4\u05d4 devolo" + "username": "\u05d3\u05d5\u05d0\"\u05dc / \u05de\u05d6\u05d4\u05d4 devolo" } }, "zeroconf_confirm": { diff --git a/homeassistant/components/devolo_home_control/translations/hu.json b/homeassistant/components/devolo_home_control/translations/hu.json index 4fa10a2a088..391eeb60727 100644 --- a/homeassistant/components/devolo_home_control/translations/hu.json +++ b/homeassistant/components/devolo_home_control/translations/hu.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "reauth_failed": "K\u00e9rj\u00fck, ugyanazt a mydevolo felhaszn\u00e1l\u00f3t haszn\u00e1lja, mint kor\u00e1bban." }, "step": { "user": { @@ -13,6 +15,13 @@ "password": "Jelsz\u00f3", "username": "E-mail / devolo ID" } + }, + "zeroconf_confirm": { + "data": { + "mydevolo_url": "mydevolo URL", + "password": "Jelsz\u00f3", + "username": "E-mail / devolo azonos\u00edt\u00f3" + } } } } diff --git a/homeassistant/components/devolo_home_control/translations/it.json b/homeassistant/components/devolo_home_control/translations/it.json index bb19fde73a9..1197702ae2a 100644 --- a/homeassistant/components/devolo_home_control/translations/it.json +++ b/homeassistant/components/devolo_home_control/translations/it.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { - "invalid_auth": "Autenticazione non valida" + "invalid_auth": "Autenticazione non valida", + "reauth_failed": "Si prega di utilizzare lo stesso utente mydevolo di prima." }, "step": { "user": { diff --git a/homeassistant/components/devolo_home_control/translations/pl.json b/homeassistant/components/devolo_home_control/translations/pl.json index 388ea692ad4..0c0f18b1d6a 100644 --- a/homeassistant/components/devolo_home_control/translations/pl.json +++ b/homeassistant/components/devolo_home_control/translations/pl.json @@ -1,10 +1,12 @@ { "config": { "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { - "invalid_auth": "Niepoprawne uwierzytelnienie" + "invalid_auth": "Niepoprawne uwierzytelnienie", + "reauth_failed": "U\u017cyj tego samego u\u017cytkownika mydevolo co poprzednio." }, "step": { "user": { diff --git a/homeassistant/components/dexcom/translations/de.json b/homeassistant/components/dexcom/translations/de.json index 20e5ee22751..be04c779390 100644 --- a/homeassistant/components/dexcom/translations/de.json +++ b/homeassistant/components/dexcom/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 72780832960..1300c165b37 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -12,6 +12,8 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PIN, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_FAHRENHEIT, ) @@ -33,8 +35,8 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None], - SENSOR_HUMIDITY: ["Humidity", PERCENTAGE], + SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_HUMIDITY: ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], } @@ -124,6 +126,7 @@ class DHTSensor(SensorEntity): self.humidity_offset = humidity_offset self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property def name(self): diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 325dbb195e9..d2d1e2ec003 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_RECEIVER_ID, DOMAIN @@ -45,7 +45,9 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): """Set up the instance.""" self.discovery_info = {} - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form() @@ -97,7 +99,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_ssdp_confirm() async def async_step_ssdp_confirm( - self, user_input: ConfigType = None + self, user_input: dict[str, Any] = None ) -> FlowResult: """Handle a confirmation flow initiated by SSDP.""" if user_input is None: diff --git a/homeassistant/components/directv/translations/de.json b/homeassistant/components/directv/translations/de.json index de5cc512940..5f06a68e715 100644 --- a/homeassistant/components/directv/translations/de.json +++ b/homeassistant/components/directv/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { @@ -14,7 +14,7 @@ "one": "eins", "other": "andere" }, - "description": "M\u00f6chten Sie {name} einrichten?" + "description": "M\u00f6chtest du {name} einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/directv/translations/he.json b/homeassistant/components/directv/translations/he.json index f057c4e4629..bc28ff4eba5 100644 --- a/homeassistant/components/directv/translations/he.json +++ b/homeassistant/components/directv/translations/he.json @@ -9,6 +9,14 @@ }, "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index d11b32a6dd5..e9ac437fe46 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,6 +3,7 @@ "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "requirements": ["async-upnp-client==0.19.1"], + "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index b2999a5ae56..36f62155b2d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -24,6 +24,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import ( CONF_NAME, CONF_URL, @@ -38,7 +40,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util import get_local_ip import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -142,7 +143,7 @@ async def async_setup_platform( async with hass.data[DLNA_DMR_DATA]["lock"]: server_host = config.get(CONF_LISTEN_IP) if server_host is None: - server_host = get_local_ip() + server_host = await async_get_source_ip(hass, PUBLIC_TARGET_IP) server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) event_handler = await async_start_event_handler( diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index f0579ef900b..d5964d5aea0 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -195,7 +195,7 @@ async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) modified = False - for importable_option in [CONF_EVENTS]: + for importable_option in (CONF_EVENTS,): if importable_option not in entry.options and importable_option in entry.data: options[importable_option] = entry.data[importable_option] modified = True diff --git a/homeassistant/components/doorbird/translations/de.json b/homeassistant/components/doorbird/translations/de.json index 640f13a73c6..3f025e67386 100644 --- a/homeassistant/components/doorbird/translations/de.json +++ b/homeassistant/components/doorbird/translations/de.json @@ -7,7 +7,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifikation", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "flow_title": "{name} ({host})", @@ -19,7 +19,7 @@ "password": "Passwort", "username": "Benutzername" }, - "title": "Stellen Sie eine Verbindung zu DoorBird her" + "title": "Stelle eine Verbindung zu DoorBird her" } } }, @@ -29,7 +29,7 @@ "data": { "events": "Durch Kommas getrennte Liste von Ereignissen." }, - "description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" + "description": "F\u00fcge f\u00fcr jedes Ereignis, das du verfolgen m\u00f6chtest, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem du sie hier eingegeben hast, verwende die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen findest du in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" } } } diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index b26caa5c865..a5e51816183 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.util import dt -from .models import DSMRSensor +from .models import DSMRSensorEntityDescription DOMAIN = "dsmr" @@ -41,217 +41,217 @@ DATA_TASK = "task" DEVICE_NAME_ENERGY = "Energy Meter" DEVICE_NAME_GAS = "Gas Meter" -SENSORS: list[DSMRSensor] = [ - DSMRSensor( +SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( + DSMRSensorEntityDescription( + key=obis_references.CURRENT_ELECTRICITY_USAGE, name="Power Consumption", - obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=DEVICE_CLASS_POWER, force_update=True, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.CURRENT_ELECTRICITY_DELIVERY, name="Power Production", - obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=DEVICE_CLASS_POWER, force_update=True, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_ACTIVE_TARIFF, name="Power Tariff", - obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF, icon="mdi:flash", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_USED_TARIFF_1, name="Energy Consumption (tarif 1)", - obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, device_class=DEVICE_CLASS_ENERGY, force_update=True, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_USED_TARIFF_2, name="Energy Consumption (tarif 2)", - obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, name="Energy Production (tarif 1)", - obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, name="Energy Production (tarif 2)", - obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, name="Power Consumption Phase L1", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, name="Power Consumption Phase L2", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, name="Power Consumption Phase L3", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, name="Power Production Phase L1", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, name="Power Production Phase L2", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, name="Power Production Phase L3", - obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.SHORT_POWER_FAILURE_COUNT, name="Short Power Failure Count", - obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, entity_registry_enabled_default=False, icon="mdi:flash-off", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.LONG_POWER_FAILURE_COUNT, name="Long Power Failure Count", - obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, entity_registry_enabled_default=False, icon="mdi:flash-off", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L1_COUNT, name="Voltage Sags Phase L1", - obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, entity_registry_enabled_default=False, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L2_COUNT, name="Voltage Sags Phase L2", - obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, entity_registry_enabled_default=False, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SAG_L3_COUNT, name="Voltage Sags Phase L3", - obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, entity_registry_enabled_default=False, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L1_COUNT, name="Voltage Swells Phase L1", - obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, entity_registry_enabled_default=False, icon="mdi:pulse", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L2_COUNT, name="Voltage Swells Phase L2", - obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, entity_registry_enabled_default=False, icon="mdi:pulse", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.VOLTAGE_SWELL_L3_COUNT, name="Voltage Swells Phase L3", - obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, entity_registry_enabled_default=False, icon="mdi:pulse", ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L1, name="Voltage Phase L1", - obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1, device_class=DEVICE_CLASS_VOLTAGE, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L2, name="Voltage Phase L2", - obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2, device_class=DEVICE_CLASS_VOLTAGE, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_VOLTAGE_L3, name="Voltage Phase L3", - obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3, device_class=DEVICE_CLASS_VOLTAGE, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L1, name="Current Phase L1", - obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1, device_class=DEVICE_CLASS_CURRENT, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L2, name="Current Phase L2", - obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2, device_class=DEVICE_CLASS_CURRENT, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.INSTANTANEOUS_CURRENT_L3, name="Current Phase L3", - obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3, device_class=DEVICE_CLASS_CURRENT, entity_registry_enabled_default=False, state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, name="Energy Consumption (total)", - obis_reference=obis_references.LUXEMBOURG_ELECTRICITY_USED_TARIFF_GLOBAL, dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, name="Energy Production (total)", - obis_reference=obis_references.LUXEMBOURG_ELECTRICITY_DELIVERED_TARIFF_GLOBAL, dsmr_versions={"5L"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", - obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"2.2", "4", "5", "5B"}, force_update=True, device_class=DEVICE_CLASS_ENERGY, last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.HOURLY_GAS_METER_READING, name="Gas Consumption", - obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, force_update=True, @@ -259,9 +259,9 @@ SENSORS: list[DSMRSensor] = [ last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_HOURLY_GAS_METER_READING, name="Gas Consumption", - obis_reference=obis_references.BELGIUM_HOURLY_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, force_update=True, @@ -269,9 +269,9 @@ SENSORS: list[DSMRSensor] = [ last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), - DSMRSensor( + DSMRSensorEntityDescription( + key=obis_references.GAS_METER_READING, name="Gas Consumption", - obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, force_update=True, @@ -279,4 +279,4 @@ SENSORS: list[DSMRSensor] = [ last_reset=dt.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, ), -] +) diff --git a/homeassistant/components/dsmr/models.py b/homeassistant/components/dsmr/models.py index b54a5af80d5..e7b47d8b74d 100644 --- a/homeassistant/components/dsmr/models.py +++ b/homeassistant/components/dsmr/models.py @@ -2,21 +2,13 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime + +from homeassistant.components.sensor import SensorEntityDescription @dataclass -class DSMRSensor: +class DSMRSensorEntityDescription(SensorEntityDescription): """Represents an DSMR Sensor.""" - name: str - obis_reference: str - - device_class: str | None = None dsmr_versions: set[str] | None = None - entity_registry_enabled_default: bool = True - force_update: bool = False - icon: str | None = None is_gas: bool = False - last_reset: datetime | None = None - state_class: str | None = None diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index cfdcbd95cf4..faff62ddeb4 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -42,7 +42,7 @@ from .const import ( LOGGER, SENSORS, ) -from .models import DSMRSensor +from .models import DSMRSensorEntityDescription PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -83,10 +83,13 @@ async def async_setup_entry( """Set up the DSMR sensor.""" dsmr_version = entry.data[CONF_DSMR_VERSION] entities = [ - DSMREntity(sensor, entry) - for sensor in SENSORS - if (sensor.dsmr_versions is None or dsmr_version in sensor.dsmr_versions) - and (not sensor.is_gas or CONF_SERIAL_ID_GAS in entry.data) + DSMREntity(description, entry) + for description in SENSORS + if ( + description.dsmr_versions is None + or dsmr_version in description.dsmr_versions + ) + and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) ] async_add_entities(entities) @@ -154,7 +157,9 @@ async def async_setup_entry( update_entities_telegram({}) # throttle reconnect attempts - await asyncio.sleep(entry.data[CONF_RECONNECT_INTERVAL]) + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except (serial.serialutil.SerialException, OSError): # Log any error while establishing connection and drop to retry @@ -162,6 +167,11 @@ async def async_setup_entry( LOGGER.exception("Error connecting to DSMR") transport = None protocol = None + + # throttle reconnect attempts + await asyncio.sleep( + entry.data.get(CONF_RECONNECT_INTERVAL, DEFAULT_RECONNECT_INTERVAL) + ) except CancelledError: if stop_listener: stop_listener() # pylint: disable=not-callable @@ -184,50 +194,46 @@ async def async_setup_entry( class DSMREntity(SensorEntity): """Entity reading values from DSMR telegram.""" + entity_description: DSMRSensorEntityDescription _attr_should_poll = False - def __init__(self, sensor: DSMRSensor, entry: ConfigEntry) -> None: + def __init__( + self, entity_description: DSMRSensorEntityDescription, entry: ConfigEntry + ) -> None: """Initialize entity.""" - self._sensor = sensor + self.entity_description = entity_description self._entry = entry self.telegram: dict[str, DSMRObject] = {} device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ENERGY - if sensor.is_gas: + if entity_description.is_gas: device_serial = entry.data[CONF_SERIAL_ID_GAS] device_name = DEVICE_NAME_GAS - self._attr_device_class = sensor.device_class self._attr_device_info = { "identifiers": {(DOMAIN, device_serial)}, "name": device_name, } - self._attr_entity_registry_enabled_default = ( - sensor.entity_registry_enabled_default + self._attr_unique_id = f"{device_serial}_{entity_description.name}".replace( + " ", "_" ) - self._attr_force_update = sensor.force_update - self._attr_icon = sensor.icon - self._attr_last_reset = sensor.last_reset - self._attr_name = sensor.name - self._attr_state_class = sensor.state_class - self._attr_unique_id = f"{device_serial}_{sensor.name}".replace(" ", "_") @callback def update_data(self, telegram: dict[str, DSMRObject]) -> None: """Update data.""" self.telegram = telegram - if self.hass and self._sensor.obis_reference in self.telegram: + if self.hass and self.entity_description.key in self.telegram: self.async_write_ha_state() def get_dsmr_object_attr(self, attribute: str) -> str | None: """Read attribute from last received telegram for this DSMR object.""" # Make sure telegram contains an object for this entities obis - if self._sensor.obis_reference not in self.telegram: + if self.entity_description.key not in self.telegram: return None # Get the attribute value if the object has it - dsmr_object = self.telegram[self._sensor.obis_reference] + dsmr_object = self.telegram[self.entity_description.key] attr: str | None = getattr(dsmr_object, attribute) return attr @@ -238,7 +244,7 @@ class DSMREntity(SensorEntity): if value is None: return None - if self._sensor.obis_reference == obis_ref.ELECTRICITY_ACTIVE_TARIFF: + if self.entity_description.key == obis_ref.ELECTRICITY_ACTIVE_TARIFF: return self.translate_tariff(value, self._entry.data[CONF_DSMR_VERSION]) with suppress(TypeError): diff --git a/homeassistant/components/dsmr/translations/es.json b/homeassistant/components/dsmr/translations/es.json index a85293e93a0..880f378c7c3 100644 --- a/homeassistant/components/dsmr/translations/es.json +++ b/homeassistant/components/dsmr/translations/es.json @@ -1,11 +1,45 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_communicate": "No se ha podido comunicar", + "cannot_connect": "No se pudo conectar" + }, + "error": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_communicate": "No se ha podido comunicar", + "cannot_connect": "No se pudo conectar" }, "step": { "one": "Vac\u00edo", - "other": "Vac\u00edo" + "other": "Vac\u00edo", + "setup_network": { + "data": { + "dsmr_version": "Seleccione la versi\u00f3n de DSMR", + "host": "Host", + "port": "Puerto" + }, + "title": "Seleccione la direcci\u00f3n de la conexi\u00f3n" + }, + "setup_serial": { + "data": { + "dsmr_version": "Seleccione la versi\u00f3n de DSMR", + "port": "Seleccione el dispositivo" + }, + "title": "Dispositivo" + }, + "setup_serial_manual_path": { + "data": { + "port": "Ruta del dispositivo USB" + }, + "title": "Ruta" + }, + "user": { + "data": { + "type": "Tipo de conexi\u00f3n" + }, + "title": "Seleccione el tipo de conexi\u00f3n" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/fr.json b/homeassistant/components/dsmr/translations/fr.json index d156aee8ca0..0f348acdbff 100644 --- a/homeassistant/components/dsmr/translations/fr.json +++ b/homeassistant/components/dsmr/translations/fr.json @@ -1,15 +1,47 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion" }, "error": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_communicate": "\u00c9chec de la communication", + "cannot_connect": "\u00c9chec de connexion", "one": "Vide", "other": "Vide" }, "step": { "one": "", - "other": "Autre" + "other": "Autre", + "setup_network": { + "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", + "host": "H\u00f4te", + "port": "Port" + }, + "title": "S\u00e9lectionner l'adresse de connexion" + }, + "setup_serial": { + "data": { + "dsmr_version": "S\u00e9lectionner la version DSMR", + "port": "S\u00e9lectionner un appareil" + }, + "title": "Appareil" + }, + "setup_serial_manual_path": { + "data": { + "port": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "title": "Chemin" + }, + "user": { + "data": { + "type": "Type de connexion" + }, + "title": "S\u00e9lectionner le type de connexion" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/he.json b/homeassistant/components/dsmr/translations/he.json index cdb921611c4..8e2195dbc42 100644 --- a/homeassistant/components/dsmr/translations/he.json +++ b/homeassistant/components/dsmr/translations/he.json @@ -1,7 +1,38 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "setup_network": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "port": "\u05e4\u05ea\u05d7\u05d4" + } + }, + "setup_serial": { + "data": { + "port": "\u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df" + }, + "title": "\u05d4\u05ea\u05e7\u05df" + }, + "setup_serial_manual_path": { + "data": { + "port": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + }, + "title": "\u05e0\u05ea\u05d9\u05d1" + }, + "user": { + "data": { + "type": "\u05e1\u05d5\u05d2 \u05d7\u05d9\u05d1\u05d5\u05e8" + }, + "title": "\u05d1\u05d7\u05e8 \u05e1\u05d5\u05d2 \u05d7\u05d9\u05d1\u05d5\u05e8" + } } } } \ No newline at end of file diff --git a/homeassistant/components/dsmr/translations/hu.json b/homeassistant/components/dsmr/translations/hu.json index 76ad4dc653f..86a15e99aab 100644 --- a/homeassistant/components/dsmr/translations/hu.json +++ b/homeassistant/components/dsmr/translations/hu.json @@ -2,24 +2,45 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "cannot_communicate": "Nem siker\u00fclt csatlakozni." + "cannot_communicate": "Nem siker\u00fclt csatlakozni.", + "cannot_connect": "Nem siker\u00fclt csatlakozni" + }, + "error": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_communicate": "Nem siker\u00fclt kommunik\u00e1lni", + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "one": "\u00dcres", + "other": "\u00dcres" }, "step": { + "one": "\u00dcres", + "other": "\u00dcres", "setup_network": { "data": { - "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa" - } + "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", + "host": "Gazdag\u00e9p", + "port": "Port" + }, + "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" }, "setup_serial": { "data": { + "dsmr_version": "DSMR verzi\u00f3 kiv\u00e1laszt\u00e1sa", "port": "Eszk\u00f6z kiv\u00e1laszt\u00e1sa" }, "title": "Eszk\u00f6z" }, + "setup_serial_manual_path": { + "data": { + "port": "USB eszk\u00f6z \u00fatvonala" + }, + "title": "\u00datvonal" + }, "user": { "data": { "type": "Kapcsolat t\u00edpusa" - } + }, + "title": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t" } } }, diff --git a/homeassistant/components/dsmr/translations/id.json b/homeassistant/components/dsmr/translations/id.json index fd8299d61ed..2e56dd3b0a6 100644 --- a/homeassistant/components/dsmr/translations/id.json +++ b/homeassistant/components/dsmr/translations/id.json @@ -1,7 +1,32 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" + }, + "error": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung" + }, + "step": { + "setup_network": { + "data": { + "host": "Host", + "port": "Port" + } + }, + "setup_serial_manual_path": { + "data": { + "port": "Jalur Perangkat USB" + }, + "title": "Jalur" + }, + "user": { + "data": { + "type": "Jenis koneksi" + }, + "title": "Pilih jenis koneksi" + } } }, "options": { diff --git a/homeassistant/components/dsmr/translations/pl.json b/homeassistant/components/dsmr/translations/pl.json index e8b8bf617f0..84a04cff625 100644 --- a/homeassistant/components/dsmr/translations/pl.json +++ b/homeassistant/components/dsmr/translations/pl.json @@ -1,9 +1,14 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_communicate": "Nie uda\u0142o si\u0119 nawi\u0105za\u0107 \u0142\u0105czno\u015bci", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" }, "error": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_communicate": "Nie uda\u0142o si\u0119 nawi\u0105za\u0107 \u0142\u0105czno\u015bci", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "few": "kilka", "many": "wiele", "one": "jeden", @@ -13,7 +18,34 @@ "few": "kilka", "many": "wiele", "one": "jeden", - "other": "inne" + "other": "inne", + "setup_network": { + "data": { + "dsmr_version": "Wybierz wersj\u0119 DSMR", + "host": "Nazwa hosta lub adres IP", + "port": "Port" + }, + "title": "Wybierz adres dla po\u0142\u0105czenia" + }, + "setup_serial": { + "data": { + "dsmr_version": "Wybierz wersj\u0119 DSMR", + "port": "Wybierz urz\u0105dzenie" + }, + "title": "Urz\u0105dzenie" + }, + "setup_serial_manual_path": { + "data": { + "port": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "\u015acie\u017cka" + }, + "user": { + "data": { + "type": "Rodzaj po\u0142\u0105czenia" + }, + "title": "Wybierz typ po\u0142\u0105czenia" + } } }, "options": { diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index d403f84e9b9..1a46f86132b 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,5 +1,13 @@ """Definitions for DSMR Reader sensors added to MQTT.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Callable + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import ( CURRENCY_EURO, DEVICE_CLASS_CURRENT, @@ -7,12 +15,13 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_KILO_WATT, - VOLT, VOLUME_CUBIC_METERS, ) +from homeassistant.util import dt as dt_util def dsmr_transform(value): @@ -29,462 +38,533 @@ def tariff_transform(value): return "high" -DEFINITIONS = { - "dsmr/reading/electricity_delivered_1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_1": { - "name": "Low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_delivered_2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_returned_2": { - "name": "High tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/reading/electricity_currently_delivered": { - "name": "Current power usage", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/electricity_currently_returned": { - "name": "Current power return", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l1": { - "name": "Current power usage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l2": { - "name": "Current power usage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_delivered_l3": { - "name": "Current power usage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l1": { - "name": "Current power return L1", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l2": { - "name": "Current power return L2", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/phase_currently_returned_l3": { - "name": "Current power return L3", - "enable_default": True, - "device_class": DEVICE_CLASS_POWER, - "unit": POWER_KILO_WATT, - }, - "dsmr/reading/extra_device_delivered": { - "name": "Gas meter usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/reading/phase_voltage_l1": { - "name": "Current voltage L1", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_voltage_l2": { - "name": "Current voltage L2", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_voltage_l3": { - "name": "Current voltage L3", - "enable_default": True, - "device_class": DEVICE_CLASS_VOLTAGE, - "unit": VOLT, - }, - "dsmr/reading/phase_power_current_l1": { - "name": "Phase power current L1", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l2": { - "name": "Phase power current L2", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/phase_power_current_l3": { - "name": "Phase power current L3", - "enable_default": True, - "device_class": DEVICE_CLASS_CURRENT, - "unit": ELECTRICAL_CURRENT_AMPERE, - }, - "dsmr/reading/timestamp": { - "name": "Telegram timestamp", - "enable_default": False, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/consumption/gas/delivered": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/currently_delivered": { - "name": "Current gas usage", - "enable_default": True, - "icon": "mdi:fire", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/consumption/gas/read_at": { - "name": "Gas meter read", - "enable_default": True, - "device_class": DEVICE_CLASS_TIMESTAMP, - }, - "dsmr/day-consumption/electricity1": { - "name": "Low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2": { - "name": "High tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_returned": { - "name": "Low tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity2_returned": { - "name": "High tariff return", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_merged": { - "name": "Power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity_returned_merged": { - "name": "Power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/day-consumption/electricity1_cost": { - "name": "Low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity2_cost": { - "name": "High tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/electricity_cost_merged": { - "name": "Power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/gas": { - "name": "Gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/day-consumption/gas_cost": { - "name": "Gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/total_cost": { - "name": "Total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_1": { - "name": "Low tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_delivered_2": { - "name": "High tariff delivered price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_1": { - "name": "Low tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_electricity_returned_2": { - "name": "High tariff returned price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/energy_supplier_price_gas": { - "name": "Gas price", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/day-consumption/fixed_cost": { - "name": "Current day fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/meter-stats/dsmr_version": { - "name": "DSMR version", - "enable_default": True, - "icon": "mdi:alert-circle", - "transform": dsmr_transform, - }, - "dsmr/meter-stats/electricity_tariff": { - "name": "Electricity tariff", - "enable_default": True, - "icon": "mdi:flash", - "transform": tariff_transform, - }, - "dsmr/meter-stats/power_failure_count": { - "name": "Power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/long_power_failure_count": { - "name": "Long power failure count", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l1": { - "name": "Voltage sag L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l2": { - "name": "Voltage sag L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_sag_count_l3": { - "name": "Voltage sag L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l1": { - "name": "Voltage swell L1", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l2": { - "name": "Voltage swell L2", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/voltage_swell_count_l3": { - "name": "Voltage swell L3", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/meter-stats/rejected_telegrams": { - "name": "Rejected telegrams", - "enable_default": True, - "icon": "mdi:flash", - }, - "dsmr/current-month/electricity1": { - "name": "Current month low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2": { - "name": "Current month high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_returned": { - "name": "Current month low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity2_returned": { - "name": "Current month high tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_merged": { - "name": "Current month power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity_returned_merged": { - "name": "Current month power return total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-month/electricity1_cost": { - "name": "Current month low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity2_cost": { - "name": "Current month high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/electricity_cost_merged": { - "name": "Current month power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/gas": { - "name": "Current month gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-month/gas_cost": { - "name": "Current month gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/fixed_cost": { - "name": "Current month fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-month/total_cost": { - "name": "Current month total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity1": { - "name": "Current year low tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_returned": { - "name": "Current year low tariff returned", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity2_returned": { - "name": "Current year high tariff usage", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_merged": { - "name": "Current year power usage total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity_returned_merged": { - "name": "Current year power returned total", - "enable_default": True, - "device_class": DEVICE_CLASS_ENERGY, - "unit": ENERGY_KILO_WATT_HOUR, - }, - "dsmr/current-year/electricity1_cost": { - "name": "Current year low tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity2_cost": { - "name": "Current year high tariff cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/electricity_cost_merged": { - "name": "Current year power total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/gas": { - "name": "Current year gas usage", - "enable_default": True, - "icon": "mdi:counter", - "unit": VOLUME_CUBIC_METERS, - }, - "dsmr/current-year/gas_cost": { - "name": "Current year gas cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/fixed_cost": { - "name": "Current year fixed cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, - "dsmr/current-year/total_cost": { - "name": "Current year total cost", - "enable_default": True, - "icon": "mdi:currency-eur", - "unit": CURRENCY_EURO, - }, -} +@dataclass +class DSMRReaderSensorEntityDescription(SensorEntityDescription): + """Sensor entity description for DSMR Reader.""" + + state: Callable | None = None + + +SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_1", + name="Low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_1", + name="Low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_delivered_2", + name="High tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_returned_2", + name="High tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_delivered", + name="Current power usage", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/electricity_currently_returned", + name="Current power return", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l1", + name="Current power usage L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l2", + name="Current power usage L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_delivered_l3", + name="Current power usage L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l1", + name="Current power return L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l2", + name="Current power return L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_currently_returned_l3", + name="Current power return L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_KILO_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/extra_device_delivered", + name="Gas meter usage", + entity_registry_enabled_default=False, + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l1", + name="Current voltage L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l2", + name="Current voltage L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_voltage_l3", + name="Current voltage L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l1", + name="Phase power current L1", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l2", + name="Phase power current L2", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/phase_power_current_l3", + name="Phase power current L3", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/reading/timestamp", + name="Telegram timestamp", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/delivered", + name="Gas usage", + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/currently_delivered", + name="Current gas usage", + icon="mdi:fire", + unit_of_measurement=VOLUME_CUBIC_METERS, + state_class=STATE_CLASS_MEASUREMENT, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/consumption/gas/read_at", + name="Gas meter read", + entity_registry_enabled_default=False, + device_class=DEVICE_CLASS_TIMESTAMP, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1", + name="Low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2", + name="High tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_returned", + name="Low tariff return", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_returned", + name="High tariff return", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_merged", + name="Power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_returned_merged", + name="Power return total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt_util.utc_from_timestamp(0), + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity1_cost", + name="Low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity2_cost", + name="High tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/electricity_cost_merged", + name="Power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas", + name="Gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/gas_cost", + name="Gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/total_cost", + name="Total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_1", + name="Low tariff delivered price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_delivered_2", + name="High tariff delivered price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_1", + name="Low tariff returned price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_electricity_returned_2", + name="High tariff returned price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/energy_supplier_price_gas", + name="Gas price", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/day-consumption/fixed_cost", + name="Current day fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/dsmr_version", + name="DSMR version", + entity_registry_enabled_default=False, + icon="mdi:alert-circle", + state=dsmr_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/electricity_tariff", + name="Electricity tariff", + icon="mdi:flash", + state=tariff_transform, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/power_failure_count", + name="Power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/long_power_failure_count", + name="Long power failure count", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l1", + name="Voltage sag L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l2", + name="Voltage sag L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_sag_count_l3", + name="Voltage sag L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l1", + name="Voltage swell L1", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l2", + name="Voltage swell L2", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/voltage_swell_count_l3", + name="Voltage swell L3", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/meter-stats/rejected_telegrams", + name="Rejected telegrams", + entity_registry_enabled_default=False, + icon="mdi:flash", + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1", + name="Current month low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2", + name="Current month high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_returned", + name="Current month low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_returned", + name="Current month high tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_merged", + name="Current month power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_returned_merged", + name="Current month power return total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity1_cost", + name="Current month low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity2_cost", + name="Current month high tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/electricity_cost_merged", + name="Current month power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas", + name="Current month gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/gas_cost", + name="Current month gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/fixed_cost", + name="Current month fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-month/total_cost", + name="Current month total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1", + name="Current year low tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2", + name="Current year high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_returned", + name="Current year low tariff returned", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_returned", + name="Current year high tariff usage", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_merged", + name="Current year power usage total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_returned_merged", + name="Current year power returned total", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity1_cost", + name="Current year low tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity2_cost", + name="Current year high tariff cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/electricity_cost_merged", + name="Current year power total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas", + name="Current year gas usage", + icon="mdi:counter", + unit_of_measurement=VOLUME_CUBIC_METERS, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/gas_cost", + name="Current year gas cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/fixed_cost", + name="Current year fixed cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), + DSMRReaderSensorEntityDescription( + key="dsmr/current-year/total_cost", + name="Current year total cost", + icon="mdi:currency-eur", + unit_of_measurement=CURRENCY_EURO, + ), +) diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py index 0ee5932c1bb..39356db46b5 100644 --- a/homeassistant/components/dsmr_reader/sensor.py +++ b/homeassistant/components/dsmr_reader/sensor.py @@ -4,39 +4,27 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.core import callback from homeassistant.util import slugify -from .definitions import DEFINITIONS +from .definitions import SENSORS, DSMRReaderSensorEntityDescription DOMAIN = "dsmr_reader" async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up DSMR Reader sensors.""" - - sensors = [] - for topic in DEFINITIONS: - sensors.append(DSMRSensor(topic)) - - async_add_entities(sensors) + async_add_entities(DSMRSensor(description) for description in SENSORS) class DSMRSensor(SensorEntity): """Representation of a DSMR sensor that is updated via MQTT.""" - def __init__(self, topic): + entity_description: DSMRReaderSensorEntityDescription + + def __init__(self, description: DSMRReaderSensorEntityDescription) -> None: """Initialize the sensor.""" + self.entity_description = description - self._definition = DEFINITIONS[topic] - - self._entity_id = slugify(topic.replace("/", "_")) - self._topic = topic - - self._name = self._definition.get("name", topic.split("/")[-1]) - self._device_class = self._definition.get("device_class") - self._enable_default = self._definition.get("enable_default") - self._unit_of_measurement = self._definition.get("unit") - self._icon = self._definition.get("icon") - self._transform = self._definition.get("transform") - self._state = None + slug = slugify(description.key.replace("/", "_")) + self.entity_id = f"sensor.{slug}" async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -44,47 +32,13 @@ class DSMRSensor(SensorEntity): @callback def message_received(message): """Handle new MQTT messages.""" - - if self._transform is not None: - self._state = self._transform(message.payload) + if self.entity_description.state is not None: + self._attr_state = self.entity_description.state(message.payload) else: - self._state = message.payload + self._attr_state = message.payload self.async_write_ha_state() - await mqtt.async_subscribe(self.hass, self._topic, message_received, 1) - - @property - def name(self): - """Return the name of the sensor supplied in constructor.""" - return self._name - - @property - def entity_id(self): - """Return the entity ID for this sensor.""" - return f"sensor.{self._entity_id}" - - @property - def state(self): - """Return the current state of the entity.""" - return self._state - - @property - def device_class(self): - """Return the device_class of this sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit_of_measurement of this sensor.""" - return self._unit_of_measurement - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enable_default - - @property - def icon(self): - """Return the icon of this sensor.""" - return self._icon + await mqtt.async_subscribe( + self.hass, self.entity_description.key, message_received, 1 + ) diff --git a/homeassistant/components/dunehd/translations/de.json b/homeassistant/components/dunehd/translations/de.json index aa87de530b8..f3d7ecd725a 100644 --- a/homeassistant/components/dunehd/translations/de.json +++ b/homeassistant/components/dunehd/translations/de.json @@ -6,7 +6,7 @@ "error": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse." + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse" }, "step": { "user": { diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 78fa9bd8552..428ed3ab427 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -9,13 +9,19 @@ Unwetterwarnungen (Stufe 3) Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ +from __future__ import annotations + from datetime import timedelta import logging from dwdwfsapi import DwdWeatherWarningsAPI import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -48,18 +54,21 @@ ADVANCE_WARNING_SENSOR = "advance_warning_level" SCAN_INTERVAL = timedelta(minutes=15) -MONITORED_CONDITIONS = { - CURRENT_WARNING_SENSOR: [ - "Current Warning Level", - None, - "mdi:close-octagon-outline", - ], - ADVANCE_WARNING_SENSOR: [ - "Advance Warning Level", - None, - "mdi:close-octagon-outline", - ], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=CURRENT_WARNING_SENSOR, + name="Current Warning Level", + icon="mdi:close-octagon-outline", + ), + SensorEntityDescription( + key=ADVANCE_WARNING_SENSOR, + name="Advance Warning Level", + icon="mdi:close-octagon-outline", + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -79,9 +88,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name)) - sensors = [] - for sensor_type in config[CONF_MONITORED_CONDITIONS]: - sensors.append(DwdWeatherWarningsSensor(api, name, sensor_type)) + sensors = [ + DwdWeatherWarningsSensor(api, name, description) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] + ] add_entities(sensors, True) @@ -89,31 +100,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DwdWeatherWarningsSensor(SensorEntity): """Representation of a DWD-Weather-Warnings sensor.""" - def __init__(self, api, name, sensor_type): + def __init__( + self, + api, + name, + description: SensorEntityDescription, + ): """Initialize a DWD-Weather-Warnings sensor.""" self._api = api - self._name = name - self._sensor_type = sensor_type - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {MONITORED_CONDITIONS[self._sensor_type][0]}" - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return MONITORED_CONDITIONS[self._sensor_type][2] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return MONITORED_CONDITIONS[self._sensor_type][1] + self.entity_description = description + self._attr_name = f"{name} {description.name}" @property def state(self): """Return the state of the device.""" - if self._sensor_type == CURRENT_WARNING_SENSOR: + if self.entity_description.key == CURRENT_WARNING_SENSOR: return self._api.api.current_warning_level return self._api.api.expected_warning_level @@ -127,7 +128,7 @@ class DwdWeatherWarningsSensor(SensorEntity): ATTR_LAST_UPDATE: self._api.api.last_update, } - if self._sensor_type == CURRENT_WARNING_SENSOR: + if self.entity_description.key == CURRENT_WARNING_SENSOR: searched_warnings = self._api.api.current_warnings else: searched_warnings = self._api.api.expected_warnings @@ -165,7 +166,7 @@ class DwdWeatherWarningsSensor(SensorEntity): "Update requested for %s (%s) by %s", self._api.api.warncell_name, self._api.api.warncell_id, - self._sensor_type, + self.entity_description.key, ) self._api.update() diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 1ee609961cc..7dc3d86afe6 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -52,6 +52,7 @@ from .const import ( SERVICE_REQUEST_AREA_PRESET, SERVICE_REQUEST_CHANNEL_LEVEL, ) +from .convert_config import convert_config def num_string(value: int | str) -> str: @@ -108,8 +109,8 @@ TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) def validate_area(config: dict[str, Any]) -> dict[str, Any]: """Validate that template parameters are only used if area is using the relevant template.""" conf_set = set() - for template in DEFAULT_TEMPLATES: - for conf in DEFAULT_TEMPLATES[template]: + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: conf_set.add(conf) if config.get(CONF_TEMPLATE): for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: @@ -263,7 +264,7 @@ async def async_entry_changed(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) - bridge = DynaliteBridge(hass, entry.data) + bridge = DynaliteBridge(hass, convert_config(entry.data)) # need to do it before the listener hass.data[DOMAIN][entry.entry_id] = bridge entry.async_on_unload(entry.add_update_listener(async_entry_changed)) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 71cecee8d43..9c911e6983d 100644 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,6 +1,7 @@ """Code to handle a Dynalite bridge.""" from __future__ import annotations +from types import MappingProxyType from typing import Any, Callable from dynalite_devices_lib.dynalite_devices import ( @@ -27,9 +28,8 @@ class DynaliteBridge: def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None: """Initialize the system based on host parameter.""" self.hass = hass - self.area = {} - self.async_add_devices = {} - self.waiting_devices = {} + self.async_add_devices: dict[str, Callable] = {} + self.waiting_devices: dict[str, list[str]] = {} self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( @@ -37,7 +37,7 @@ class DynaliteBridge: update_device_func=self.update_device, notification_func=self.handle_notification, ) - self.dynalite_devices.configure(convert_config(config)) + self.dynalite_devices.configure(config) async def async_setup(self) -> bool: """Set up a Dynalite bridge.""" @@ -45,7 +45,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - def reload_config(self, config: dict[str, Any]) -> None: + def reload_config(self, config: MappingProxyType[str, Any]) -> None: """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(convert_config(config)) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index e1d062a6058..d148d09354f 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_HOST from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER +from .convert_config import convert_config class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -25,11 +26,13 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): host = import_info[CONF_HOST] for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: - if entry.data != import_info: - self.hass.config_entries.async_update_entry(entry, data=import_info) + self.hass.config_entries.async_update_entry( + entry, data=dict(import_info) + ) return self.async_abort(reason="already_configured") + # New entry - bridge = DynaliteBridge(self.hass, import_info) + bridge = DynaliteBridge(self.hass, convert_config(import_info)) if not await bridge.async_setup(): LOGGER.error("Unable to setup bridge - import info=%s", import_info) return self.async_abort(reason="no_connection") diff --git a/homeassistant/components/dynalite/convert_config.py b/homeassistant/components/dynalite/convert_config.py index 89a7f32b47a..4abc02c0565 100644 --- a/homeassistant/components/dynalite/convert_config.py +++ b/homeassistant/components/dynalite/convert_config.py @@ -1,6 +1,7 @@ """Convert the HA config to the dynalite config.""" from __future__ import annotations +from types import MappingProxyType from typing import Any from dynalite_devices_lib import const as dyn_const @@ -136,7 +137,9 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]: return convert_with_map(config, my_map) -def convert_config(config: dict[str, Any]) -> dict[str, Any]: +def convert_config( + config: dict[str, Any] | MappingProxyType[str, Any] +) -> dict[str, Any]: """Convert a config dict by replacing component consts with library consts.""" my_map = { CONF_NAME: dyn_const.CONF_NAME, diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index ebb1dd23795..56def12afbe 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -43,7 +43,7 @@ class DynaliteBase(Entity): """Initialize the base class.""" self._device = device self._bridge = bridge - self._unsub_dispatchers = [] + self._unsub_dispatchers: list[Callable[[], None]] = [] @property def name(self) -> str: diff --git a/homeassistant/components/eafm/translations/de.json b/homeassistant/components/eafm/translations/de.json index 46185acc11b..c82a21b1c3e 100644 --- a/homeassistant/components/eafm/translations/de.json +++ b/homeassistant/components/eafm/translations/de.json @@ -9,8 +9,8 @@ "data": { "station": "Station" }, - "description": "W\u00e4hlen Sie die Station aus, die Sie \u00fcberwachen m\u00f6chten", - "title": "Verfolgen Sie eine Hochwasser\u00fcberwachungsstation" + "description": "W\u00e4hle die Station aus, die du \u00fcberwachen m\u00f6chtest", + "title": "Verfolge eine Hochwasser\u00fcberwachungsstation" } } } diff --git a/homeassistant/components/eafm/translations/hu.json b/homeassistant/components/eafm/translations/hu.json index 3b2d79a34a7..38863029f12 100644 --- a/homeassistant/components/eafm/translations/hu.json +++ b/homeassistant/components/eafm/translations/hu.json @@ -2,6 +2,13 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "station": "\u00c1llom\u00e1s" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 72d169f389e..e27c6fe0772 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -3,6 +3,8 @@ Support for EBox. Get data from 'My Usage Page' page: https://client.ebox.ca/myusage """ +from __future__ import annotations + from datetime import timedelta import logging @@ -10,7 +12,11 @@ from pyebox import EboxClient from pyebox.client import PyEboxError import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, @@ -34,25 +40,87 @@ REQUESTS_TIMEOUT = 15 SCAN_INTERVAL = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SENSOR_TYPES = { - "usage": ["Usage", PERCENTAGE, "mdi:percent"], - "balance": ["Balance", PRICE, "mdi:cash-usd"], - "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], - "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], - "before_offpeak_download": [ - "Download before offpeak", - DATA_GIGABITS, - "mdi:download", - ], - "before_offpeak_upload": ["Upload before offpeak", DATA_GIGABITS, "mdi:upload"], - "before_offpeak_total": ["Total before offpeak", DATA_GIGABITS, "mdi:download"], - "offpeak_download": ["Offpeak download", DATA_GIGABITS, "mdi:download"], - "offpeak_upload": ["Offpeak Upload", DATA_GIGABITS, "mdi:upload"], - "offpeak_total": ["Offpeak Total", DATA_GIGABITS, "mdi:download"], - "download": ["Download", DATA_GIGABITS, "mdi:download"], - "upload": ["Upload", DATA_GIGABITS, "mdi:upload"], - "total": ["Total", DATA_GIGABITS, "mdi:download"], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="usage", + name="Usage", + unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + ), + SensorEntityDescription( + key="balance", + name="Balance", + unit_of_measurement=PRICE, + icon="mdi:cash-usd", + ), + SensorEntityDescription( + key="limit", + name="Data limit", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="days_left", + name="Days left", + unit_of_measurement=TIME_DAYS, + icon="mdi:calendar-today", + ), + SensorEntityDescription( + key="before_offpeak_download", + name="Download before offpeak", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="before_offpeak_upload", + name="Upload before offpeak", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="before_offpeak_total", + name="Total before offpeak", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="offpeak_download", + name="Offpeak download", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="offpeak_upload", + name="Offpeak Upload", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="offpeak_total", + name="Offpeak Total", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="download", + name="Download", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), + SensorEntityDescription( + key="upload", + name="Upload", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:upload", + ), + SensorEntityDescription( + key="total", + name="Total", + unit_of_measurement=DATA_GIGABITS, + icon="mdi:download", + ), +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -82,9 +150,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.error("Failed login: %s", exp) raise PlatformNotReady from exp - sensors = [] - for variable in config[CONF_MONITORED_VARIABLES]: - sensors.append(EBoxSensor(ebox_data, variable, name)) + sensors = [ + EBoxSensor(ebox_data, description, name) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_VARIABLES] + ] async_add_entities(sensors, True) @@ -92,41 +162,24 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class EBoxSensor(SensorEntity): """Implementation of a EBox sensor.""" - def __init__(self, ebox_data, sensor_type, name): + def __init__( + self, + ebox_data, + description: SensorEntityDescription, + name, + ): """Initialize the sensor.""" - self.client_name = name - self.type = sensor_type - self._name = SENSOR_TYPES[sensor_type][0] - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._icon = SENSOR_TYPES[sensor_type][2] + self.entity_description = description + self._attr_name = f"{name} {description.name}" self.ebox_data = ebox_data - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon async def async_update(self): """Get the latest data from EBox and update the state.""" await self.ebox_data.async_update() - if self.type in self.ebox_data.data: - self._state = round(self.ebox_data.data[self.type], 2) + if self.entity_description.key in self.ebox_data.data: + self._attr_state = round( + self.ebox_data.data[self.entity_description.key], 2 + ) class EBoxData: diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index c4ff789202d..7052a9950fd 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,5 +1,6 @@ """Constants for ebus component.""" from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, PERCENTAGE, PRESSURE_BAR, @@ -17,127 +18,243 @@ SENSOR_TYPES = { "ActualFlowTemperatureDesired": [ "Hc1ActualFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, ], "MaxFlowTemperatureDesired": [ "Hc1MaxFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, ], "MinFlowTemperatureDesired": [ "Hc1MinFlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, ], - "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2], + "PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None], "HCSummerTemperatureLimit": [ "Hc1SummerTempLimit", TEMP_CELSIUS, "mdi:weather-sunny", 0, + DEVICE_CLASS_TEMPERATURE, + ], + "HolidayTemperature": [ + "HolidayTemp", + TEMP_CELSIUS, + None, + 0, + DEVICE_CLASS_TEMPERATURE, + ], + "HWTemperatureDesired": [ + "HwcTempDesired", + TEMP_CELSIUS, + None, + 0, + DEVICE_CLASS_TEMPERATURE, + ], + "HWActualTemperature": [ + "HwcStorageTemp", + TEMP_CELSIUS, + None, + 0, + DEVICE_CLASS_TEMPERATURE, + ], + "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None], + "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None], + "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None], + "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None], + "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None], + "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None], + "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None], + "HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None], + "WaterPressure": ["WaterPressure", PRESSURE_BAR, "mdi:water-pump", 0, None], + "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None], + "Zone1NightTemperature": [ + "z1NightTemp", + TEMP_CELSIUS, + "mdi:weather-night", + 0, + DEVICE_CLASS_TEMPERATURE, + ], + "Zone1DayTemperature": [ + "z1DayTemp", + TEMP_CELSIUS, + "mdi:weather-sunny", + 0, + DEVICE_CLASS_TEMPERATURE, ], - "HolidayTemperature": ["HolidayTemp", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWTemperatureDesired": ["HwcTempDesired", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWActualTemperature": ["HwcStorageTemp", TEMP_CELSIUS, "mdi:thermometer", 0], - "HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1], - "HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1], - "HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1], - "HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1], - "HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1], - "HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1], - "HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1], - "HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3], - "WaterPressure": ["WaterPressure", PRESSURE_BAR, "mdi:water-pump", 0], - "Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0], - "Zone1NightTemperature": ["z1NightTemp", TEMP_CELSIUS, "mdi:weather-night", 0], - "Zone1DayTemperature": ["z1DayTemp", TEMP_CELSIUS, "mdi:weather-sunny", 0], "Zone1HolidayTemperature": [ "z1HolidayTemp", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, + ], + "Zone1RoomTemperature": [ + "z1RoomTemp", + TEMP_CELSIUS, + None, + 0, + DEVICE_CLASS_TEMPERATURE, ], - "Zone1RoomTemperature": ["z1RoomTemp", TEMP_CELSIUS, "mdi:thermometer", 0], "Zone1ActualRoomTemperatureDesired": [ "z1ActualRoomTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, + ], + "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None], + "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None], + "Zone1TimerWednesday": [ + "z1Timer.Wednesday", + None, + "mdi:timer-outline", + 1, + None, + ], + "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None], + "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None], + "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None], + "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None], + "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None], + "ContinuosHeating": [ + "ContinuosHeating", + TEMP_CELSIUS, + "mdi:weather-snowy", + 0, + DEVICE_CLASS_TEMPERATURE, ], - "Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1], - "Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1], - "Zone1TimerWednesday": ["z1Timer.Wednesday", None, "mdi:timer-outline", 1], - "Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1], - "Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1], - "Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1], - "Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1], - "Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3], - "ContinuosHeating": ["ContinuosHeating", TEMP_CELSIUS, "mdi:weather-snowy", 0], "PowerEnergyConsumptionLastMonth": [ "PrEnergySumHcLastMonth", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], "PowerEnergyConsumptionThisMonth": [ "PrEnergySumHcThisMonth", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], }, "ehp": { - "HWTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "OutsideTemp": ["OutsideTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "HWTemperature": ["HwcTemp", TEMP_CELSIUS, None, 4, DEVICE_CLASS_TEMPERATURE], + "OutsideTemp": ["OutsideTemp", TEMP_CELSIUS, None, 4, DEVICE_CLASS_TEMPERATURE], }, "bai": { - "HotWaterTemperature": ["HwcTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "StorageTemperature": ["StorageTemp", TEMP_CELSIUS, "mdi:thermometer", 4], + "HotWaterTemperature": [ + "HwcTemp", + TEMP_CELSIUS, + None, + 4, + DEVICE_CLASS_TEMPERATURE, + ], + "StorageTemperature": [ + "StorageTemp", + TEMP_CELSIUS, + None, + 4, + DEVICE_CLASS_TEMPERATURE, + ], "DesiredStorageTemperature": [ "StorageTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, ], "OutdoorsTemperature": [ "OutdoorstempSensor", TEMP_CELSIUS, - "mdi:thermometer", + None, 4, + DEVICE_CLASS_TEMPERATURE, ], - "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4], - "AverageIgnitionTime": ["averageIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "MaximumIgnitionTime": ["maxIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "MinimumIgnitionTime": ["minIgnitiontime", TIME_SECONDS, "mdi:av-timer", 0], - "ReturnTemperature": ["ReturnTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2], - "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2], + "WaterPreasure": ["WaterPressure", PRESSURE_BAR, "mdi:pipe", 4, None], + "AverageIgnitionTime": [ + "averageIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "MaximumIgnitionTime": [ + "maxIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "MinimumIgnitionTime": [ + "minIgnitiontime", + TIME_SECONDS, + "mdi:av-timer", + 0, + None, + ], + "ReturnTemperature": [ + "ReturnTemp", + TEMP_CELSIUS, + None, + 4, + DEVICE_CLASS_TEMPERATURE, + ], + "CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None], + "HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None], "DesiredFlowTemperature": [ "FlowTempDesired", TEMP_CELSIUS, - "mdi:thermometer", + None, 0, + DEVICE_CLASS_TEMPERATURE, ], - "FlowTemperature": ["FlowTemp", TEMP_CELSIUS, "mdi:thermometer", 4], - "Flame": ["Flame", None, "mdi:toggle-switch", 2], + "FlowTemperature": [ + "FlowTemp", + TEMP_CELSIUS, + None, + 4, + DEVICE_CLASS_TEMPERATURE, + None, + ], + "Flame": ["Flame", None, "mdi:toggle-switch", 2, None], "PowerEnergyConsumptionHeatingCircuit": [ "PrEnergySumHc1", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, ], "PowerEnergyConsumptionHotWaterCircuit": [ "PrEnergySumHwc1", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0, + None, + ], + "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None], + "HeatingPartLoad": [ + "PartloadHcKW", + ENERGY_KILO_WATT_HOUR, + "mdi:flash", + 0, + None, + ], + "StateNumber": ["StateNumber", None, "mdi:fire", 3, None], + "ModulationPercentage": [ + "ModulationTempDesired", + PERCENTAGE, + "mdi:percent", + 0, + None, ], - "RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2], - "HeatingPartLoad": ["PartloadHcKW", ENERGY_KILO_WATT_HOUR, "mdi:flash", 0], - "StateNumber": ["StateNumber", None, "mdi:fire", 3], - "ModulationPercentage": ["ModulationTempDesired", PERCENTAGE, "mdi:percent", 0], }, } diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index 00f6a6b2b3e..abd9620130d 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -41,7 +41,13 @@ class EbusdSensor(SensorEntity): """Initialize the sensor.""" self._state = None self._client_name = name - self._name, self._unit_of_measurement, self._icon, self._type = sensor + ( + self._name, + self._unit_of_measurement, + self._icon, + self._type, + self._device_class, + ) = sensor self.data = data @property @@ -77,6 +83,11 @@ class EbusdSensor(SensorEntity): return schedule return None + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + @property def icon(self): """Icon to use in the frontend, if any.""" diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py index e1c9308b5a9..9a2fbdd9b87 100644 --- a/homeassistant/components/ecoal_boiler/sensor.py +++ b/homeassistant/components/ecoal_boiler/sensor.py @@ -1,6 +1,6 @@ """Allows reading temperatures from ecoal/esterownik.pl controller.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from . import AVAILABLE_SENSORS, DATA_ECOAL_BOILER @@ -37,6 +37,11 @@ class EcoalTempSensor(SensorEntity): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_TEMPERATURE + @property def unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index 275db46ab0a..24ba36bedd8 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -12,8 +12,8 @@ from homeassistant.const import ( from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_FAHRENHEIT], - "humidity": ["Humidity", PERCENTAGE], + "temperature": ["Temperature", TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE], + "humidity": ["Humidity", PERCENTAGE, DEVICE_CLASS_HUMIDITY], } @@ -44,6 +44,7 @@ class EcobeeSensor(SensorEntity): self.index = sensor_index self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property def name(self): diff --git a/homeassistant/components/ecobee/translations/de.json b/homeassistant/components/ecobee/translations/de.json index 0c89a696b2c..10edbd4ecd1 100644 --- a/homeassistant/components/ecobee/translations/de.json +++ b/homeassistant/components/ecobee/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits eingerichtet. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "pin_request_failed": "Fehler beim Anfordern der PIN von ecobee; Bitte \u00fcberpr\u00fcfe, ob der API-Schl\u00fcssel korrekt ist.", @@ -14,7 +14,7 @@ }, "user": { "data": { - "api_key": "API Schl\u00fcssel" + "api_key": "API-Schl\u00fcssel" }, "description": "Bitte gib den von ecobee.com erhaltenen API-Schl\u00fcssel ein.", "title": "ecobee API-Schl\u00fcssel" diff --git a/homeassistant/components/econet/translations/hu.json b/homeassistant/components/econet/translations/hu.json index 065c648d4a0..0f9cf18f203 100644 --- a/homeassistant/components/econet/translations/hu.json +++ b/homeassistant/components/econet/translations/hu.json @@ -14,7 +14,8 @@ "data": { "email": "E-mail", "password": "Jelsz\u00f3" - } + }, + "title": "\u00c1ll\u00edtsa be a Rheem EcoNet fi\u00f3kot" } } } diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 28711821f50..9adb7665753 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_NAME, + DEVICE_CLASS_TEMPERATURE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, @@ -117,6 +118,11 @@ class EddystoneTemp(SensorEntity): """Return the state of the device.""" return self.temperature + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_TEMPERATURE + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index 16502632f4f..d2d0d375733 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -10,13 +10,15 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import CONF_NAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) @@ -35,7 +37,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the EDL21 sensor.""" hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) await hass.data[DOMAIN].connect() @@ -126,7 +133,7 @@ class EDL21: def __init__(self, hass, config, async_add_entities) -> None: """Initialize an EDL21 object.""" - self._registered_obis = set() + self._registered_obis: set[tuple[str, str]] = set() self._hass = hass self._async_add_entities = async_add_entities self._name = config[CONF_NAME] diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 4e16cd1087f..f839b3fcc74 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -148,8 +148,7 @@ async def async_setup(hass, config): sensors = [] binary_sensors = [] if eight.users: - for user in eight.users: - obj = eight.users[user] + for obj in eight.users.values(): for sensor in SENSORS: sensors.append(f"{obj.side}_{sensor}") binary_sensors.append(f"{obj.side}_presence") diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index ae0854ec244..01413ceaec0 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -2,7 +2,12 @@ import logging from homeassistant.components.sensor import SensorEntity -from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from . import ( CONF_SENSORS, @@ -172,10 +177,11 @@ class EightUserSensor(EightSleepUserEntity, SensorEntity): return None @property - def icon(self): - """Icon to use in the frontend, if any.""" + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" if "bed_temp" in self._sensor: - return "mdi:thermometer" + return DEVICE_CLASS_TEMPERATURE + return None async def async_update(self): """Retrieve latest state.""" @@ -334,6 +340,6 @@ class EightRoomSensor(EightSleepUserEntity, SensorEntity): return TEMP_FAHRENHEIT @property - def icon(self): - """Icon to use in the frontend, if any.""" - return "mdi:thermometer" + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_TEMPERATURE diff --git a/homeassistant/components/elgato/translations/de.json b/homeassistant/components/elgato/translations/de.json index 95bb2609d84..6ff531919cb 100644 --- a/homeassistant/components/elgato/translations/de.json +++ b/homeassistant/components/elgato/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Elgato Key Light-Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 4a75ccb242e..8f26af545b7 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -9,7 +9,7 @@ from elkm1_lib.util import pretty_const, username import voluptuous as vol from homeassistant.components.sensor import SensorEntity -from homeassistant.const import VOLT +from homeassistant.const import ELECTRIC_POTENTIAL_VOLT from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform @@ -255,7 +255,7 @@ class ElkZone(ElkSensor): if self._element.definition == ZoneType.TEMPERATURE.value: return self._temperature_unit if self._element.definition == ZoneType.ANALOG_ZONE.value: - return VOLT + return ELECTRIC_POTENTIAL_VOLT return None def _element_changed(self, element, changeset): diff --git a/homeassistant/components/elkm1/translations/de.json b/homeassistant/components/elkm1/translations/de.json index 8157a061d82..137f781fd05 100644 --- a/homeassistant/components/elkm1/translations/de.json +++ b/homeassistant/components/elkm1/translations/de.json @@ -14,13 +14,13 @@ "data": { "address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.", "password": "Passwort", - "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).", + "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn du nur einen ElkM1 hast).", "protocol": "Protokoll", "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.", "username": "Benutzername" }, "description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.", - "title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her" + "title": "Stelle eine Verbindung zur Elk-M1-Steuerung her" } } } diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index 5656a1f1486..c562cf400b6 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -86,7 +86,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Handle devices which are added to Emby.""" new_devices = [] active_devices = [] - for dev_id in emby.devices: + for dev_id, dev in emby.devices.items(): active_devices.append(dev_id) if ( dev_id not in active_emby_devices @@ -96,9 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= active_emby_devices[dev_id] = new new_devices.append(new) - elif ( - dev_id in inactive_emby_devices and emby.devices[dev_id].state != "Off" - ): + elif dev_id in inactive_emby_devices and dev.state != "Off": add = inactive_emby_devices.pop(dev_id) active_emby_devices[dev_id] = add _LOGGER.debug("Showing %s, item: %s", dev_id, add) diff --git a/homeassistant/components/emonitor/translations/de.json b/homeassistant/components/emonitor/translations/de.json index c36f7a5ae77..b1b10756c31 100644 --- a/homeassistant/components/emonitor/translations/de.json +++ b/homeassistant/components/emonitor/translations/de.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "M\u00f6chten Sie {name} ({host}) einrichten?", + "description": "M\u00f6chtest du {name} ({host}) einrichten?", "title": "Einrichtung SiteSage Emonitor" }, "user": { diff --git a/homeassistant/components/emonitor/translations/he.json b/homeassistant/components/emonitor/translations/he.json index 4ec15aa12cb..77bd85b18b8 100644 --- a/homeassistant/components/emonitor/translations/he.json +++ b/homeassistant/components/emonitor/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py index 4e577929644..45c9355603f 100644 --- a/homeassistant/components/emulated_roku/__init__.py +++ b/homeassistant/components/emulated_roku/__init__.py @@ -1,7 +1,9 @@ """Support for Roku API emulation.""" import voluptuous as vol -from homeassistant import config_entries, util +from homeassistant import config_entries +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv @@ -71,7 +73,9 @@ async def async_setup_entry(hass, config_entry): name = config[CONF_NAME] listen_port = config[CONF_LISTEN_PORT] - host_ip = config.get(CONF_HOST_IP) or util.get_local_ip() + host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip( + hass, PUBLIC_TARGET_IP + ) advertise_ip = config.get(CONF_ADVERTISE_IP) advertise_port = config.get(CONF_ADVERTISE_PORT) upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST) diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json index 6ef54d1d1cc..36a86137e87 100644 --- a/homeassistant/components/emulated_roku/manifest.json +++ b/homeassistant/components/emulated_roku/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emulated_roku", "requirements": ["emulated_roku==0.2.1"], + "dependencies": ["network"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/emulated_roku/translations/hu.json b/homeassistant/components/emulated_roku/translations/hu.json index bccfe3bdcab..e733e9801df 100644 --- a/homeassistant/components/emulated_roku/translations/hu.json +++ b/homeassistant/components/emulated_roku/translations/hu.json @@ -6,9 +6,12 @@ "step": { "user": { "data": { + "advertise_ip": "IP c\u00edm k\u00f6zl\u00e9se", + "advertise_port": "Port k\u00f6zl\u00e9se", "host_ip": "Hoszt IP c\u00edm", "listen_port": "Port figyel\u00e9se", - "name": "N\u00e9v" + "name": "N\u00e9v", + "upnp_bind_multicast": "K\u00f6t\u00f6tt multicast (igaz/hamis)" }, "title": "A kiszolg\u00e1l\u00f3 szerver konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py new file mode 100644 index 00000000000..30a1bf8e877 --- /dev/null +++ b/homeassistant/components/energy/__init__.py @@ -0,0 +1,34 @@ +"""The Energy integration.""" +from __future__ import annotations + +from homeassistant.components import frontend +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from . import websocket_api +from .const import DOMAIN +from .data import async_get_manager + + +async def is_configured(hass: HomeAssistant) -> bool: + """Return a boolean to indicate if energy is configured.""" + manager = await async_get_manager(hass) + if manager.data is None: + return False + return bool(manager.data != manager.default_preferences()) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Energy.""" + websocket_api.async_setup(hass) + frontend.async_register_built_in_panel(hass, DOMAIN, DOMAIN, "mdi:lightning-bolt") + + hass.async_create_task( + discovery.async_load_platform(hass, "sensor", DOMAIN, {}, config) + ) + hass.data[DOMAIN] = { + "cost_sensors": {}, + } + + return True diff --git a/homeassistant/components/energy/const.py b/homeassistant/components/energy/const.py new file mode 100644 index 00000000000..26093a93433 --- /dev/null +++ b/homeassistant/components/energy/const.py @@ -0,0 +1,3 @@ +"""Constants for the Energy integration.""" + +DOMAIN = "energy" diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py new file mode 100644 index 00000000000..c053dea4741 --- /dev/null +++ b/homeassistant/components/energy/data.py @@ -0,0 +1,261 @@ +"""Energy data.""" +from __future__ import annotations + +import asyncio +from collections import Counter +from collections.abc import Awaitable +from typing import Callable, Literal, Optional, TypedDict, Union, cast + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, singleton, storage + +from .const import DOMAIN + +STORAGE_VERSION = 1 +STORAGE_KEY = DOMAIN + + +@singleton.singleton(f"{DOMAIN}_manager") +async def async_get_manager(hass: HomeAssistant) -> EnergyManager: + """Return an initialized data manager.""" + manager = EnergyManager(hass) + await manager.async_initialize() + return manager + + +class FlowFromGridSourceType(TypedDict): + """Dictionary describing the 'from' stat for the grid source.""" + + # statistic_id of a an energy meter (kWh) + stat_energy_from: str + + # statistic_id of costs ($) incurred from the energy meter + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_cost: str | None + + # Used to generate costs if stat_cost is set to None + entity_energy_from: str | None # entity_id of an energy meter (kWh), entity_id of the energy meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/kWh) + number_energy_price: float | None # Price for energy ($/kWh) + + +class FlowToGridSourceType(TypedDict): + """Dictionary describing the 'to' stat for the grid source.""" + + # kWh meter + stat_energy_to: str + + # statistic_id of compensation ($) received for contributing back + # If set to None and entity_energy_from and entity_energy_price are configured, + # an EnergyCostSensor will be automatically created + stat_compensation: str | None + + # Used to generate costs if stat_compensation is set to None + entity_energy_from: str | None # entity_id of an energy meter (kWh), entity_id of the energy meter for stat_energy_from + entity_energy_price: str | None # entity_id of an entity providing price ($/kWh) + number_energy_price: float | None # Price for energy ($/kWh) + + +class GridSourceType(TypedDict): + """Dictionary holding the source of grid energy consumption.""" + + type: Literal["grid"] + + flow_from: list[FlowFromGridSourceType] + flow_to: list[FlowToGridSourceType] + + cost_adjustment_day: float + + +class SolarSourceType(TypedDict): + """Dictionary holding the source of energy production.""" + + type: Literal["solar"] + + stat_energy_from: str + config_entry_solar_forecast: list[str] | None + + +SourceType = Union[GridSourceType, SolarSourceType] + + +class DeviceConsumption(TypedDict): + """Dictionary holding the source of individual device consumption.""" + + # This is an ever increasing value + stat_consumption: str + + +class EnergyPreferences(TypedDict): + """Dictionary holding the energy data.""" + + energy_sources: list[SourceType] + device_consumption: list[DeviceConsumption] + + +class EnergyPreferencesUpdate(EnergyPreferences, total=False): + """all types optional.""" + + +def _flow_from_ensure_single_price( + val: FlowFromGridSourceType, +) -> FlowFromGridSourceType: + """Ensure we use a single price source.""" + if ( + val["entity_energy_price"] is not None + and val["number_energy_price"] is not None + ): + raise vol.Invalid("Define either an entity or a fixed number for the price") + + return val + + +FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required("stat_energy_from"): str, + vol.Optional("stat_cost"): vol.Any(str, None), + vol.Optional("entity_energy_from"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } + ), + _flow_from_ensure_single_price, +) + + +FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("stat_energy_to"): str, + vol.Optional("stat_compensation"): vol.Any(str, None), + vol.Optional("entity_energy_to"): vol.Any(str, None), + vol.Optional("entity_energy_price"): vol.Any(str, None), + vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None), + } +) + + +def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]: + """Generate a validator that ensures a value is only used once.""" + + def validate_uniqueness( + val: list[dict], + ) -> list[dict]: + """Ensure that the user doesn't add duplicate values.""" + counts = Counter(flow_from[key] for flow_from in val) + + for value, count in counts.items(): + if count > 1: + raise vol.Invalid(f"Cannot specify {value} more than once") + + return val + + return validate_uniqueness + + +GRID_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "grid", + vol.Required("flow_from"): vol.All( + [FLOW_FROM_GRID_SOURCE_SCHEMA], + _generate_unique_value_validator("stat_energy_from"), + ), + vol.Required("flow_to"): vol.All( + [FLOW_TO_GRID_SOURCE_SCHEMA], + _generate_unique_value_validator("stat_energy_to"), + ), + vol.Required("cost_adjustment_day"): vol.Coerce(float), + } +) +SOLAR_SOURCE_SCHEMA = vol.Schema( + { + vol.Required("type"): "solar", + vol.Required("stat_energy_from"): str, + vol.Optional("config_entry_solar_forecast"): vol.Any([str], None), + } +) + + +def check_type_limits(value: list[SourceType]) -> list[SourceType]: + """Validate that we don't have too many of certain types.""" + types = Counter([val["type"] for val in value]) + + if types.get("grid", 0) > 1: + raise vol.Invalid("You cannot have more than 1 grid source") + + return value + + +ENERGY_SOURCE_SCHEMA = vol.All( + vol.Schema( + [ + cv.key_value_schemas( + "type", + { + "grid": GRID_SOURCE_SCHEMA, + "solar": SOLAR_SOURCE_SCHEMA, + }, + ) + ] + ), + check_type_limits, +) + +DEVICE_CONSUMPTION_SCHEMA = vol.Schema( + { + vol.Required("stat_consumption"): str, + } +) + + +class EnergyManager: + """Manage the instance energy prefs.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize energy manager.""" + self._hass = hass + self._store = storage.Store(hass, STORAGE_VERSION, STORAGE_KEY) + self.data: EnergyPreferences | None = None + self._update_listeners: list[Callable[[], Awaitable]] = [] + + async def async_initialize(self) -> None: + """Initialize the energy integration.""" + self.data = cast(Optional[EnergyPreferences], await self._store.async_load()) + + @staticmethod + def default_preferences() -> EnergyPreferences: + """Return default preferences.""" + return { + "energy_sources": [], + "device_consumption": [], + } + + async def async_update(self, update: EnergyPreferencesUpdate) -> None: + """Update the preferences.""" + if self.data is None: + data = EnergyManager.default_preferences() + else: + data = self.data.copy() + + for key in ( + "energy_sources", + "device_consumption", + ): + if key in update: + data[key] = update[key] # type: ignore + + self.data = data + self._store.async_delay_save(lambda: cast(dict, self.data), 60) + + if not self._update_listeners: + return + + await asyncio.gather(*(listener() for listener in self._update_listeners)) + + @callback + def async_listen_updates(self, update_listener: Callable[[], Awaitable]) -> None: + """Listen for data updates.""" + self._update_listeners.append(update_listener) diff --git a/homeassistant/components/energy/manifest.json b/homeassistant/components/energy/manifest.json new file mode 100644 index 00000000000..3a3cbeff4e7 --- /dev/null +++ b/homeassistant/components/energy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "energy", + "name": "Energy", + "documentation": "https://www.home-assistant.io/integrations/energy", + "codeowners": ["@home-assistant/core"], + "iot_class": "calculated", + "dependencies": ["websocket_api", "history"], + "quality_scale": "internal" +} diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py new file mode 100644 index 00000000000..e974035cbd6 --- /dev/null +++ b/homeassistant/components/energy/sensor.py @@ -0,0 +1,286 @@ +"""Helper sensor for calculating utility costs.""" +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +import logging +from typing import Any, Final, Literal, TypeVar, cast + +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + DEVICE_CLASS_MONETARY, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, +) +from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +import homeassistant.util.dt as dt_util + +from .const import DOMAIN +from .data import EnergyManager, async_get_manager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the energy sensors.""" + manager = await async_get_manager(hass) + process_now = partial(_process_manager_data, hass, manager, async_add_entities, {}) + manager.async_listen_updates(process_now) + + if manager.data: + await process_now() + + +T = TypeVar("T") + + +@dataclass +class FlowAdapter: + """Adapter to allow flows to be used as sensors.""" + + flow_type: Literal["flow_from", "flow_to"] + stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] + entity_energy_key: Literal["entity_energy_from", "entity_energy_to"] + total_money_key: Literal["stat_cost", "stat_compensation"] + name_suffix: str + entity_id_suffix: str + + +FLOW_ADAPTERS: Final = ( + FlowAdapter( + "flow_from", + "stat_energy_from", + "entity_energy_from", + "stat_cost", + "Cost", + "cost", + ), + FlowAdapter( + "flow_to", + "stat_energy_to", + "entity_energy_to", + "stat_compensation", + "Compensation", + "compensation", + ), +) + + +async def _process_manager_data( + hass: HomeAssistant, + manager: EnergyManager, + async_add_entities: AddEntitiesCallback, + current_entities: dict[tuple[str, str], EnergyCostSensor], +) -> None: + """Process updated data.""" + to_add: list[SensorEntity] = [] + to_remove = dict(current_entities) + + async def finish() -> None: + if to_add: + async_add_entities(to_add) + + for key, entity in to_remove.items(): + current_entities.pop(key) + await entity.async_remove() + + if not manager.data: + await finish() + return + + for energy_source in manager.data["energy_sources"]: + if energy_source["type"] != "grid": + continue + + for adapter in FLOW_ADAPTERS: + for flow in energy_source[adapter.flow_type]: + # Opting out of the type complexity because can't get it to work + untyped_flow = cast(dict, flow) + + # No need to create an entity if we already have a cost stat + if untyped_flow.get(adapter.total_money_key) is not None: + continue + + # This is unique among all flow_from's + key = (adapter.flow_type, untyped_flow[adapter.stat_energy_key]) + + # Make sure the right data is there + # If the entity existed, we don't pop it from to_remove so it's removed + if untyped_flow.get(adapter.entity_energy_key) is None or ( + untyped_flow.get("entity_energy_price") is None + and untyped_flow.get("number_energy_price") is None + ): + continue + + current_entity = to_remove.pop(key, None) + if current_entity: + current_entity.update_config(untyped_flow) + continue + + current_entities[key] = EnergyCostSensor( + adapter, + untyped_flow, + ) + to_add.append(current_entities[key]) + + await finish() + + +class EnergyCostSensor(SensorEntity): + """Calculate costs incurred by consuming energy. + + This is intended as a fallback for when no specific cost sensor is available for the + utility. + """ + + def __init__( + self, + adapter: FlowAdapter, + flow: dict, + ) -> None: + """Initialize the sensor.""" + super().__init__() + + self._adapter = adapter + self.entity_id = f"{flow[adapter.entity_energy_key]}_{adapter.entity_id_suffix}" + self._attr_device_class = DEVICE_CLASS_MONETARY + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._flow = flow + self._last_energy_sensor_state: State | None = None + self._cur_value = 0.0 + + def _reset(self, energy_state: State) -> None: + """Reset the cost sensor.""" + self._attr_state = 0.0 + self._cur_value = 0.0 + self._attr_last_reset = dt_util.utcnow() + self._last_energy_sensor_state = energy_state + self.async_write_ha_state() + + @callback + def _update_cost(self) -> None: + """Update incurred costs.""" + energy_state = self.hass.states.get( + cast(str, self._flow[self._adapter.entity_energy_key]) + ) + + if energy_state is None or ATTR_LAST_RESET not in energy_state.attributes: + return + + try: + energy = float(energy_state.state) + except ValueError: + return + + # Determine energy price + if self._flow["entity_energy_price"] is not None: + energy_price_state = self.hass.states.get(self._flow["entity_energy_price"]) + + if energy_price_state is None: + return + + try: + energy_price = float(energy_price_state.state) + except ValueError: + return + + if energy_price_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, "").endswith( + f"/{ENERGY_WATT_HOUR}" + ): + energy_price *= 1000.0 + + else: + energy_price_state = None + energy_price = cast(float, self._flow["number_energy_price"]) + + if self._last_energy_sensor_state is None: + # Initialize as it's the first time all required entities are in place. + self._reset(energy_state) + return + + energy_unit = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if energy_unit == ENERGY_WATT_HOUR: + energy_price /= 1000 + elif energy_unit != ENERGY_KILO_WATT_HOUR: + _LOGGER.warning( + "Found unexpected unit %s for %s", energy_unit, energy_state.entity_id + ) + return + + if ( + energy_state.attributes[ATTR_LAST_RESET] + != self._last_energy_sensor_state.attributes[ATTR_LAST_RESET] + ): + # Energy meter was reset, reset cost sensor too + self._reset(energy_state) + else: + # Update with newly incurred cost + old_energy_value = float(self._last_energy_sensor_state.state) + self._cur_value += (energy - old_energy_value) * energy_price + self._attr_state = round(self._cur_value, 2) + + self._last_energy_sensor_state = energy_state + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + energy_state = self.hass.states.get(self._flow[self._adapter.entity_energy_key]) + if energy_state: + name = energy_state.name + else: + name = split_entity_id(self._flow[self._adapter.entity_energy_key])[ + 0 + ].replace("_", " ") + + self._attr_name = f"{name} {self._adapter.name_suffix}" + + self._update_cost() + + # Store stat ID in hass.data so frontend can look it up + self.hass.data[DOMAIN]["cost_sensors"][ + self._flow[self._adapter.entity_energy_key] + ] = self.entity_id + + @callback + def async_state_changed_listener(*_: Any) -> None: + """Handle child updates.""" + self._update_cost() + self.async_write_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, + cast(str, self._flow[self._adapter.entity_energy_key]), + async_state_changed_listener, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Handle removing from hass.""" + self.hass.data[DOMAIN]["cost_sensors"].pop( + self._flow[self._adapter.entity_energy_key] + ) + await super().async_will_remove_from_hass() + + @callback + def update_config(self, flow: dict) -> None: + """Update the config.""" + self._flow = flow + + @property + def unit_of_measurement(self) -> str | None: + """Return the units of measurement.""" + return self.hass.config.currency diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json new file mode 100644 index 00000000000..6cdcd827633 --- /dev/null +++ b/homeassistant/components/energy/strings.json @@ -0,0 +1,3 @@ +{ + "title": "Energy" +} diff --git a/homeassistant/components/energy/translations/ca.json b/homeassistant/components/energy/translations/ca.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/ca.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/cs.json b/homeassistant/components/energy/translations/cs.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/cs.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/de.json b/homeassistant/components/energy/translations/de.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/de.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/en.json b/homeassistant/components/energy/translations/en.json new file mode 100644 index 00000000000..109e1bd5af8 --- /dev/null +++ b/homeassistant/components/energy/translations/en.json @@ -0,0 +1,3 @@ +{ + "title": "Energy" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/et.json b/homeassistant/components/energy/translations/et.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/et.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/fr.json b/homeassistant/components/energy/translations/fr.json new file mode 100644 index 00000000000..f947a07baec --- /dev/null +++ b/homeassistant/components/energy/translations/fr.json @@ -0,0 +1,3 @@ +{ + "title": "\u00c9nergie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/he.json b/homeassistant/components/energy/translations/he.json new file mode 100644 index 00000000000..3c61aad6089 --- /dev/null +++ b/homeassistant/components/energy/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/it.json b/homeassistant/components/energy/translations/it.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/it.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/nl.json b/homeassistant/components/energy/translations/nl.json new file mode 100644 index 00000000000..53457a69447 --- /dev/null +++ b/homeassistant/components/energy/translations/nl.json @@ -0,0 +1,3 @@ +{ + "title": "Energie" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/pl.json b/homeassistant/components/energy/translations/pl.json new file mode 100644 index 00000000000..c8d85790fdd --- /dev/null +++ b/homeassistant/components/energy/translations/pl.json @@ -0,0 +1,3 @@ +{ + "title": "Energia" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/ru.json b/homeassistant/components/energy/translations/ru.json new file mode 100644 index 00000000000..b351e407168 --- /dev/null +++ b/homeassistant/components/energy/translations/ru.json @@ -0,0 +1,3 @@ +{ + "title": "\u042d\u043d\u0435\u0440\u0433\u0438\u044f" +} \ No newline at end of file diff --git a/homeassistant/components/energy/translations/zh-Hant.json b/homeassistant/components/energy/translations/zh-Hant.json new file mode 100644 index 00000000000..bae50fae66e --- /dev/null +++ b/homeassistant/components/energy/translations/zh-Hant.json @@ -0,0 +1,3 @@ +{ + "title": "\u80fd\u6e90" +} \ No newline at end of file diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py new file mode 100644 index 00000000000..d1c8869a1c2 --- /dev/null +++ b/homeassistant/components/energy/websocket_api.py @@ -0,0 +1,115 @@ +"""The Energy websocket API.""" +from __future__ import annotations + +import asyncio +import functools +from typing import Any, Awaitable, Callable, Dict, cast + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .data import ( + DEVICE_CONSUMPTION_SCHEMA, + ENERGY_SOURCE_SCHEMA, + EnergyManager, + EnergyPreferencesUpdate, + async_get_manager, +) + +EnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + None, +] +AsyncEnergyWebSocketCommandHandler = Callable[ + [HomeAssistant, websocket_api.ActiveConnection, Dict[str, Any], "EnergyManager"], + Awaitable[None], +] + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the energy websocket API.""" + websocket_api.async_register_command(hass, ws_get_prefs) + websocket_api.async_register_command(hass, ws_save_prefs) + websocket_api.async_register_command(hass, ws_info) + + +def _ws_with_manager( + func: Any, +) -> websocket_api.WebSocketCommandHandler: + """Decorate a function to pass in a manager.""" + + @websocket_api.async_response + @functools.wraps(func) + async def with_manager( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict + ) -> None: + manager = await async_get_manager(hass) + + result = func(hass, connection, msg, manager) + + if asyncio.iscoroutine(result): + await result + + return with_manager + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/get_prefs", + } +) +@_ws_with_manager +@callback +def ws_get_prefs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle get prefs command.""" + if manager.data is None: + connection.send_error(msg["id"], websocket_api.ERR_NOT_FOUND, "No prefs") + return + + connection.send_result(msg["id"], manager.data) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/save_prefs", + vol.Optional("energy_sources"): ENERGY_SOURCE_SCHEMA, + vol.Optional("device_consumption"): [DEVICE_CONSUMPTION_SCHEMA], + } +) +@_ws_with_manager +async def ws_save_prefs( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, + manager: EnergyManager, +) -> None: + """Handle get prefs command.""" + msg_id = msg.pop("id") + msg.pop("type") + await manager.async_update(cast(EnergyPreferencesUpdate, msg)) + connection.send_result(msg_id, manager.data) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "energy/info", + } +) +@callback +def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + connection.send_result(msg["id"], hass.data[DOMAIN]) diff --git a/homeassistant/components/enocean/translations/de.json b/homeassistant/components/enocean/translations/de.json index a8e4e2c7f84..63a3cf73ca8 100644 --- a/homeassistant/components/enocean/translations/de.json +++ b/homeassistant/components/enocean/translations/de.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_dongle_path": "Ung\u00fcltiger Dongle-Pfad", - "single_instance_allowed": "Schon konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "invalid_dongle_path": "Kein g\u00fcltiger Dongle unter diesem Pfad gefunden" @@ -12,13 +12,13 @@ "data": { "path": "USB-Dongle-Pfad" }, - "title": "W\u00e4hlen Sie den Pfad zu Ihrem ENOcean-Dongle" + "title": "W\u00e4hle den Pfad zu deinem ENOcean-Dongle" }, "manual": { "data": { "path": "USB-Dongle-Pfad" }, - "title": "Geben Sie den Pfad zu Ihrem ENOcean-Dongle ein" + "title": "Gib den Pfad zu deinem ENOcean-Dongle ein" } } } diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index dfd6b782408..69c488169a6 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -47,9 +47,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except httpx.HTTPError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err - for condition in SENSORS: - if condition != "inverters": - data[condition] = await getattr(envoy_reader, condition)() + for description in SENSORS: + if description.key != "inverters": + data[description.key] = await getattr( + envoy_reader, description.key + )() else: data[ "inverters_production" diff --git a/homeassistant/components/enphase_envoy/const.py b/homeassistant/components/enphase_envoy/const.py index 7a1de25e242..9f87a821787 100644 --- a/homeassistant/components/enphase_envoy/const.py +++ b/homeassistant/components/enphase_envoy/const.py @@ -1,8 +1,12 @@ """The enphase_envoy component.""" -from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) +from homeassistant.const import DEVICE_CLASS_ENERGY, ENERGY_WATT_HOUR, POWER_WATT +from homeassistant.util import dt DOMAIN = "enphase_envoy" @@ -12,22 +16,67 @@ PLATFORMS = ["sensor"] COORDINATOR = "coordinator" NAME = "name" -SENSORS = { - "production": ("Current Energy Production", POWER_WATT, STATE_CLASS_MEASUREMENT), - "daily_production": ("Today's Energy Production", ENERGY_WATT_HOUR, None), - "seven_days_production": ( - "Last Seven Days Energy Production", - ENERGY_WATT_HOUR, - None, +SENSORS = ( + SensorEntityDescription( + key="production", + name="Current Power Production", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, ), - "lifetime_production": ("Lifetime Energy Production", ENERGY_WATT_HOUR, None), - "consumption": ("Current Energy Consumption", POWER_WATT, STATE_CLASS_MEASUREMENT), - "daily_consumption": ("Today's Energy Consumption", ENERGY_WATT_HOUR, None), - "seven_days_consumption": ( - "Last Seven Days Energy Consumption", - ENERGY_WATT_HOUR, - None, + SensorEntityDescription( + key="daily_production", + name="Today's Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, ), - "lifetime_consumption": ("Lifetime Energy Consumption", ENERGY_WATT_HOUR, None), - "inverters": ("Inverter", POWER_WATT, STATE_CLASS_MEASUREMENT), -} + SensorEntityDescription( + key="seven_days_production", + name="Last Seven Days Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="lifetime_production", + name="Lifetime Energy Production", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + last_reset=dt.utc_from_timestamp(0), + ), + SensorEntityDescription( + key="consumption", + name="Current Power Consumption", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key="daily_consumption", + name="Today's Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="seven_days_consumption", + name="Last Seven Days Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + ), + SensorEntityDescription( + key="lifetime_consumption", + name="Lifetime Energy Consumption", + unit_of_measurement=ENERGY_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + device_class=DEVICE_CLASS_ENERGY, + last_reset=dt.utc_from_timestamp(0), + ), + SensorEntityDescription( + key="inverters", + name="Inverter", + unit_of_measurement=POWER_WATT, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 5ccb540efd0..29d273401f4 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -56,43 +56,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = data[NAME] entities = [] - for condition in SENSORS: - entity_name = "" + for sensor_description in SENSORS: if ( - condition == "inverters" + sensor_description.key == "inverters" and coordinator.data.get("inverters_production") is not None ): for inverter in coordinator.data["inverters_production"]: - entity_name = f"{name} {SENSORS[condition][0]} {inverter}" + entity_name = f"{name} {sensor_description.name} {inverter}" split_name = entity_name.split(" ") serial_number = split_name[-1] entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, serial_number, - SENSORS[condition][1], - SENSORS[condition][2], coordinator, ) ) - elif condition != "inverters": - data = coordinator.data.get(condition) + elif sensor_description.key != "inverters": + data = coordinator.data.get(sensor_description.key) if isinstance(data, str) and "not available" in data: continue - entity_name = f"{name} {SENSORS[condition][0]}" + entity_name = f"{name} {sensor_description.name}" entities.append( Envoy( - condition, + sensor_description, entity_name, name, config_entry.unique_id, None, - SENSORS[condition][1], - SENSORS[condition][2], coordinator, ) ) @@ -105,23 +100,19 @@ class Envoy(CoordinatorEntity, SensorEntity): def __init__( self, - sensor_type, + description, name, device_name, device_serial_number, serial_number, - unit, - state_class, coordinator, ): """Initialize Envoy entity.""" - self._type = sensor_type + self.entity_description = description self._name = name self._serial_number = serial_number self._device_name = device_name self._device_serial_number = device_serial_number - self._unit_of_measurement = unit - self._attr_state_class = state_class super().__init__(coordinator) @@ -136,16 +127,16 @@ class Envoy(CoordinatorEntity, SensorEntity): if self._serial_number: return self._serial_number if self._device_serial_number: - return f"{self._device_serial_number}_{self._type}" + return f"{self._device_serial_number}_{self.entity_description.key}" @property def state(self): """Return the state of the sensor.""" - if self._type != "inverters": - value = self.coordinator.data.get(self._type) + if self.entity_description.key != "inverters": + value = self.coordinator.data.get(self.entity_description.key) elif ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( @@ -156,11 +147,6 @@ class Envoy(CoordinatorEntity, SensorEntity): return value - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - @property def icon(self): """Icon to use in the frontend, if any.""" @@ -170,7 +156,7 @@ class Envoy(CoordinatorEntity, SensorEntity): def extra_state_attributes(self): """Return the state attributes.""" if ( - self._type == "inverters" + self.entity_description.key == "inverters" and self.coordinator.data.get("inverters_production") is not None ): value = self.coordinator.data.get("inverters_production").get( diff --git a/homeassistant/components/enphase_envoy/translations/fr.json b/homeassistant/components/enphase_envoy/translations/fr.json index be1d5f3bca3..9587739e88a 100644 --- a/homeassistant/components/enphase_envoy/translations/fr.json +++ b/homeassistant/components/enphase_envoy/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", diff --git a/homeassistant/components/enphase_envoy/translations/hu.json b/homeassistant/components/enphase_envoy/translations/hu.json index 3449489bd87..ab92a4ad2bb 100644 --- a/homeassistant/components/enphase_envoy/translations/hu.json +++ b/homeassistant/components/enphase_envoy/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", diff --git a/homeassistant/components/enphase_envoy/translations/id.json b/homeassistant/components/enphase_envoy/translations/id.json index 74e3e8a66c7..ba3f8dd8cc6 100644 --- a/homeassistant/components/enphase_envoy/translations/id.json +++ b/homeassistant/components/enphase_envoy/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 0f0fb04fd00..232bc558da1 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ATTR_LOCATION, CONF_LATITUDE, CONF_LONGITUDE, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv @@ -77,6 +78,7 @@ class ECSensor(SensorEntity): self._state = None self._attr = None self._unit = None + self._device_class = None @property def unique_id(self) -> str: @@ -103,6 +105,11 @@ class ECSensor(SensorEntity): """Return the units of measurement.""" return self._unit + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return self._device_class + def update(self): """Update current conditions.""" self.ec_data.update() @@ -135,6 +142,7 @@ class ECSensor(SensorEntity): "humidex", ]: self._unit = TEMP_CELSIUS + self._device_class = DEVICE_CLASS_TEMPERATURE else: self._unit = sensor_data.get("unit") diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index a4a8a02cee9..cf24146da14 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -28,7 +28,7 @@ from homeassistant.components.weather import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt +from homeassistant.util import dt CONF_FORECAST = "forecast" CONF_ATTRIBUTION = "Data provided by Environment Canada" diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py index 137d6aee853..9bca552326a 100644 --- a/homeassistant/components/envirophat/sensor.py +++ b/homeassistant/components/envirophat/sensor.py @@ -9,9 +9,10 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, + DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_VOLT, PRESSURE_HPA, TEMP_CELSIUS, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -24,22 +25,22 @@ CONF_USE_LEDS = "use_leds" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) SENSOR_TYPES = { - "light": ["light", " ", "mdi:weather-sunny"], - "light_red": ["light_red", " ", "mdi:invert-colors"], - "light_green": ["light_green", " ", "mdi:invert-colors"], - "light_blue": ["light_blue", " ", "mdi:invert-colors"], - "accelerometer_x": ["accelerometer_x", "G", "mdi:earth"], - "accelerometer_y": ["accelerometer_y", "G", "mdi:earth"], - "accelerometer_z": ["accelerometer_z", "G", "mdi:earth"], - "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet"], - "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet"], - "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet"], - "temperature": ["temperature", TEMP_CELSIUS, "mdi:thermometer"], - "pressure": ["pressure", PRESSURE_HPA, "mdi:gauge"], - "voltage_0": ["voltage_0", VOLT, "mdi:flash"], - "voltage_1": ["voltage_1", VOLT, "mdi:flash"], - "voltage_2": ["voltage_2", VOLT, "mdi:flash"], - "voltage_3": ["voltage_3", VOLT, "mdi:flash"], + "light": ["light", " ", "mdi:weather-sunny", None], + "light_red": ["light_red", " ", "mdi:invert-colors", None], + "light_green": ["light_green", " ", "mdi:invert-colors", None], + "light_blue": ["light_blue", " ", "mdi:invert-colors", None], + "accelerometer_x": ["accelerometer_x", "G", "mdi:earth", None], + "accelerometer_y": ["accelerometer_y", "G", "mdi:earth", None], + "accelerometer_z": ["accelerometer_z", "G", "mdi:earth", None], + "magnetometer_x": ["magnetometer_x", " ", "mdi:magnet", None], + "magnetometer_y": ["magnetometer_y", " ", "mdi:magnet", None], + "magnetometer_z": ["magnetometer_z", " ", "mdi:magnet", None], + "temperature": ["temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + "pressure": ["pressure", PRESSURE_HPA, "mdi:gauge", None], + "voltage_0": ["voltage_0", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "voltage_1": ["voltage_1", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "voltage_2": ["voltage_2", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], + "voltage_3": ["voltage_3", ELECTRIC_POTENTIAL_VOLT, "mdi:flash", None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -91,6 +92,11 @@ class EnvirophatSensor(SensorEntity): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self.type][3] + @property def icon(self): """Icon to use in the frontend, if any.""" diff --git a/homeassistant/components/epson/translations/hu.json b/homeassistant/components/epson/translations/hu.json index 4f70feb6ec1..8e0d7ec9a18 100644 --- a/homeassistant/components/epson/translations/hu.json +++ b/homeassistant/components/epson/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "powered_off": "A projektor be van kapcsolva? A kezdeti konfigur\u00e1l\u00e1shoz be kell kapcsolnia a kivet\u00edt\u0151t." }, "step": { "user": { diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 22f74e1c0b1..2f483b9fcbf 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -1,22 +1,60 @@ """Support for Epson Workforce Printer.""" +from __future__ import annotations + from datetime import timedelta from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, PERCENTAGE from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -MONITORED_CONDITIONS = { - "black": ["Ink level Black", PERCENTAGE, "mdi:water"], - "photoblack": ["Ink level Photoblack", PERCENTAGE, "mdi:water"], - "magenta": ["Ink level Magenta", PERCENTAGE, "mdi:water"], - "cyan": ["Ink level Cyan", PERCENTAGE, "mdi:water"], - "yellow": ["Ink level Yellow", PERCENTAGE, "mdi:water"], - "clean": ["Cleaning level", PERCENTAGE, "mdi:water"], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="black", + name="Ink level Black", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="photoblack", + name="Ink level Photoblack", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="magenta", + name="Ink level Magenta", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="cyan", + name="Ink level Cyan", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="yellow", + name="Ink level Yellow", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), + SensorEntityDescription( + key="clean", + name="Cleaning level", + icon="mdi:water", + unit_of_measurement=PERCENTAGE, + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -37,8 +75,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): raise PlatformNotReady() sensors = [ - EpsonPrinterCartridge(api, condition) - for condition in config[CONF_MONITORED_CONDITIONS] + EpsonPrinterCartridge(api, description) + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] ] add_devices(sensors, True) @@ -47,34 +86,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class EpsonPrinterCartridge(SensorEntity): """Representation of a cartridge sensor.""" - def __init__(self, api, cartridgeidx): + def __init__(self, api, description: SensorEntityDescription): """Initialize a cartridge sensor.""" self._api = api - - self._id = cartridgeidx - self._name = MONITORED_CONDITIONS[self._id][0] - self._unit = MONITORED_CONDITIONS[self._id][1] - self._icon = MONITORED_CONDITIONS[self._id][2] - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit + self.entity_description = description @property def state(self): """Return the state of the device.""" - return self._api.getSensorValue(self._id) + return self._api.getSensorValue(self.entity_description.key) @property def available(self): diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index aa7da100505..2efe005230f 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -2,15 +2,17 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable from dataclasses import dataclass, field import functools import logging import math -from typing import Generic, TypeVar +from typing import Any, Callable, Generic, TypeVar, cast, overload from aioesphomeapi import ( APIClient, APIConnectionError, + APIIntEnum, APIVersion, DeviceInfo as EsphomeDeviceInfo, EntityInfo, @@ -32,13 +34,14 @@ from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, ServiceCall, State, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.service import async_set_service_schema @@ -97,7 +100,7 @@ class DomainData: """Get the global DomainData instance stored in hass.data.""" # Don't use setdefault - this is a hot code path if DOMAIN in hass.data: - return hass.data[DOMAIN] + return cast(_T, hass.data[DOMAIN]) ret = hass.data[DOMAIN] = cls() return ret @@ -153,7 +156,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if service.data_template: try: data_template = { - key: Template(value) for key, value in service.data_template.items() + key: Template(value) # type: ignore[no-untyped-call] + for key, value in service.data_template.items() } template.attach(hass, data_template) service_data.update( @@ -197,10 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: send_state = state.state if attribute: - send_state = state.attributes[attribute] + attr_val = state.attributes[attribute] # ESPHome only handles "on"/"off" for boolean values - if isinstance(send_state, bool): - send_state = "on" if send_state else "off" + if isinstance(attr_val, bool): + send_state = "on" if attr_val else "off" + else: + send_state = attr_val await cli.send_home_assistant_state(entity_id, attribute, str(send_state)) @@ -253,6 +259,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nonlocal device_id try: entry_data.device_info = await cli.device_info() + assert cli.api_version is not None entry_data.api_version = cli.api_version entry_data.available = True device_id = await _async_setup_device_registry( @@ -304,9 +311,9 @@ class ReconnectLogic(RecordUpdateListener): cli: APIClient, entry: ConfigEntry, host: str, - on_login, + on_login: Callable[[], Awaitable[None]], zc: Zeroconf, - ): + ) -> None: """Initialize ReconnectingLogic.""" self._hass = hass self._cli = cli @@ -322,12 +329,12 @@ class ReconnectLogic(RecordUpdateListener): # Event the different strategies use for issuing a reconnect attempt. self._reconnect_event = asyncio.Event() # The task containing the infinite reconnect loop while running - self._loop_task: asyncio.Task | None = None + self._loop_task: asyncio.Task[None] | None = None # How many reconnect attempts have there been already, used for exponential wait time self._tries = 0 self._tries_lock = asyncio.Lock() # Track the wait task to cancel it on HA shutdown - self._wait_task: asyncio.Task | None = None + self._wait_task: asyncio.Task[None] | None = None self._wait_task_lock = asyncio.Lock() @property @@ -338,7 +345,7 @@ class ReconnectLogic(RecordUpdateListener): except KeyError: return None - async def _on_disconnect(self): + async def _on_disconnect(self) -> None: """Log and issue callbacks when disconnecting.""" if self._entry_data is None: return @@ -364,7 +371,7 @@ class ReconnectLogic(RecordUpdateListener): self._connected = False self._reconnect_event.set() - async def _wait_and_start_reconnect(self): + async def _wait_and_start_reconnect(self) -> None: """Wait for exponentially increasing time to issue next reconnect event.""" async with self._tries_lock: tries = self._tries @@ -383,7 +390,7 @@ class ReconnectLogic(RecordUpdateListener): self._wait_task = None self._reconnect_event.set() - async def _try_connect(self): + async def _try_connect(self) -> None: """Try connecting to the API client.""" async with self._tries_lock: tries = self._tries @@ -421,7 +428,7 @@ class ReconnectLogic(RecordUpdateListener): await self._stop_zc_listen() self._hass.async_create_task(self._on_login()) - async def _reconnect_once(self): + async def _reconnect_once(self) -> None: # Wait and clear reconnection event await self._reconnect_event.wait() self._reconnect_event.clear() @@ -429,7 +436,7 @@ class ReconnectLogic(RecordUpdateListener): # If in connected state, do not try to connect again. async with self._connected_lock: if self._connected: - return False + return # Check if the entry got removed or disabled, in which case we shouldn't reconnect if not DomainData.get(self._hass).is_entry_loaded(self._entry): @@ -448,7 +455,7 @@ class ReconnectLogic(RecordUpdateListener): await self._try_connect() - async def _reconnect_loop(self): + async def _reconnect_loop(self) -> None: while True: try: await self._reconnect_once() @@ -457,7 +464,7 @@ class ReconnectLogic(RecordUpdateListener): except Exception: # pylint: disable=broad-except _LOGGER.error("Caught exception while reconnecting", exc_info=True) - async def start(self): + async def start(self) -> None: """Start the reconnecting logic background task.""" # Create reconnection loop outside of HA's tracked tasks in order # not to delay startup. @@ -467,7 +474,7 @@ class ReconnectLogic(RecordUpdateListener): self._connected = False self._reconnect_event.set() - async def stop(self): + async def stop(self) -> None: """Stop the reconnecting logic background task. Does not disconnect the client.""" if self._loop_task is not None: self._loop_task.cancel() @@ -478,7 +485,7 @@ class ReconnectLogic(RecordUpdateListener): self._wait_task = None await self._stop_zc_listen() - async def _start_zc_listen(self): + async def _start_zc_listen(self) -> None: """Listen for mDNS records. This listener allows us to schedule a reconnect as soon as a @@ -491,7 +498,7 @@ class ReconnectLogic(RecordUpdateListener): ) self._zc_listening = True - async def _stop_zc_listen(self): + async def _stop_zc_listen(self) -> None: """Stop listening for zeroconf updates.""" async with self._zc_lock: if self._zc_listening: @@ -499,12 +506,12 @@ class ReconnectLogic(RecordUpdateListener): self._zc_listening = False @callback - def stop_callback(self): + def stop_callback(self) -> None: """Stop as an async callback function.""" self._hass.async_create_task(self.stop()) @callback - def _set_reconnect(self): + def _set_reconnect(self) -> None: self._reconnect_event.set() def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: @@ -535,13 +542,13 @@ class ReconnectLogic(RecordUpdateListener): async def _async_setup_device_registry( hass: HomeAssistant, entry: ConfigEntry, device_info: EsphomeDeviceInfo -): +) -> str: """Set up device registry feature for a particular config entry.""" sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += f" ({device_info.compilation_time})" device_registry = await dr.async_get_registry(hass) - entry = device_registry.async_get_or_create( + device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, name=device_info.name, @@ -549,63 +556,76 @@ async def _async_setup_device_registry( model=device_info.model, sw_version=sw_version, ) - return entry.id + return device_entry.id + + +ARG_TYPE_METADATA = { + UserServiceArgType.BOOL: { + "validator": cv.boolean, + "example": "False", + "selector": {"boolean": None}, + }, + UserServiceArgType.INT: { + "validator": vol.Coerce(int), + "example": "42", + "selector": {"number": {CONF_MODE: "box"}}, + }, + UserServiceArgType.FLOAT: { + "validator": vol.Coerce(float), + "example": "12.3", + "selector": {"number": {CONF_MODE: "box", "step": 1e-3}}, + }, + UserServiceArgType.STRING: { + "validator": cv.string, + "example": "Example text", + "selector": {"text": None}, + }, + UserServiceArgType.BOOL_ARRAY: { + "validator": [cv.boolean], + "description": "A list of boolean values.", + "example": "[True, False]", + "selector": {"object": {}}, + }, + UserServiceArgType.INT_ARRAY: { + "validator": [vol.Coerce(int)], + "description": "A list of integer values.", + "example": "[42, 34]", + "selector": {"object": {}}, + }, + UserServiceArgType.FLOAT_ARRAY: { + "validator": [vol.Coerce(float)], + "description": "A list of floating point numbers.", + "example": "[ 12.3, 34.5 ]", + "selector": {"object": {}}, + }, + UserServiceArgType.STRING_ARRAY: { + "validator": [cv.string], + "description": "A list of strings.", + "example": "['Example text', 'Another example']", + "selector": {"object": {}}, + }, +} async def _register_service( hass: HomeAssistant, entry_data: RuntimeEntryData, service: UserService -): +) -> None: + if entry_data.device_info is None: + raise ValueError("Device Info needs to be fetched first") service_name = f"{entry_data.device_info.name.replace('-', '_')}_{service.name}" schema = {} fields = {} for arg in service.args: - metadata = { - UserServiceArgType.BOOL: { - "validator": cv.boolean, - "example": "False", - "selector": {"boolean": None}, - }, - UserServiceArgType.INT: { - "validator": vol.Coerce(int), - "example": "42", - "selector": {"number": {CONF_MODE: "box"}}, - }, - UserServiceArgType.FLOAT: { - "validator": vol.Coerce(float), - "example": "12.3", - "selector": {"number": {CONF_MODE: "box", "step": 1e-3}}, - }, - UserServiceArgType.STRING: { - "validator": cv.string, - "example": "Example text", - "selector": {"text": None}, - }, - UserServiceArgType.BOOL_ARRAY: { - "validator": [cv.boolean], - "description": "A list of boolean values.", - "example": "[True, False]", - "selector": {"object": {}}, - }, - UserServiceArgType.INT_ARRAY: { - "validator": [vol.Coerce(int)], - "description": "A list of integer values.", - "example": "[42, 34]", - "selector": {"object": {}}, - }, - UserServiceArgType.FLOAT_ARRAY: { - "validator": [vol.Coerce(float)], - "description": "A list of floating point numbers.", - "example": "[ 12.3, 34.5 ]", - "selector": {"object": {}}, - }, - UserServiceArgType.STRING_ARRAY: { - "validator": [cv.string], - "description": "A list of strings.", - "example": "['Example text', 'Another example']", - "selector": {"object": {}}, - }, - }[arg.type] + if arg.type not in ARG_TYPE_METADATA: + _LOGGER.error( + "Can't register service %s because %s is of unknown type %s", + service_name, + arg.name, + arg.type, + ) + return + metadata = ARG_TYPE_METADATA[arg.type] schema[vol.Required(arg.name)] = metadata["validator"] fields[arg.name] = { "name": arg.name, @@ -615,8 +635,8 @@ async def _register_service( "selector": metadata["selector"], } - async def execute_service(call): - await entry_data.client.execute_service(service, call.data) + async def execute_service(call: ServiceCall) -> None: + await entry_data.client.execute_service(service, call.data) # type: ignore[arg-type] hass.services.async_register( DOMAIN, service_name, execute_service, vol.Schema(schema) @@ -632,7 +652,10 @@ async def _register_service( async def _setup_services( hass: HomeAssistant, entry_data: RuntimeEntryData, services: list[UserService] -): +) -> None: + if entry_data.device_info is None: + # Can happen if device has never connected or .storage cleared + return old_services = entry_data.services.copy() to_unregister = [] to_register = [] @@ -688,15 +711,20 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await DomainData.get(hass).get_or_create_store(hass, entry).async_remove() +_InfoT = TypeVar("_InfoT", bound=EntityInfo) +_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") +_StateT = TypeVar("_StateT", bound=EntityState) + + async def platform_async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, - async_add_entities, + async_add_entities: AddEntitiesCallback, *, component_key: str, - info_type, - entity_type, - state_type, + info_type: type[_InfoT], + entity_type: type[_EntityT], + state_type: type[_StateT], ) -> None: """Set up an esphome platform. @@ -709,15 +737,17 @@ async def platform_async_setup_entry( entry_data.state[component_key] = {} @callback - def async_list_entities(infos: list[EntityInfo]): + def async_list_entities(infos: list[EntityInfo]) -> None: """Update entities of this platform when entities are listed.""" old_infos = entry_data.info[component_key] - new_infos = {} + new_infos: dict[int, EntityInfo] = {} add_entities = [] for info in infos: if not isinstance(info, info_type): # Filter out infos that don't belong to this platform. continue + # cast back to upper type, otherwise mypy gets confused + info = cast(EntityInfo, info) if info.key in old_infos: # Update existing entity @@ -746,10 +776,13 @@ async def platform_async_setup_entry( ) @callback - def async_entity_state(state: EntityState): + def async_entity_state(state: EntityState) -> None: """Notify the appropriate entity of an updated state.""" if not isinstance(state, state_type): return + # cast back to upper type, otherwise mypy gets confused + state = cast(EntityState, state) + entry_data.state[component_key][state.key] = state entry_data.async_update_entity(hass, component_key, state.key) @@ -759,16 +792,20 @@ async def platform_async_setup_entry( ) -def esphome_state_property(func): +_PropT = TypeVar("_PropT", bound=Callable[..., Any]) + + +def esphome_state_property(func: _PropT) -> _PropT: """Wrap a state property of an esphome entity. This checks if the state object in the entity is set, and prevents writing NAN values to the Home Assistant state machine. """ - @property - def _wrapper(self): - if self._state is None: + @property # type: ignore[misc] + @functools.wraps(func) + def _wrapper(self): # type: ignore[no-untyped-def] + if not self._has_state: return None val = func(self) if isinstance(val, float) and math.isnan(val): @@ -777,29 +814,43 @@ def esphome_state_property(func): return None return val - return _wrapper + return cast(_PropT, _wrapper) -class EsphomeEnumMapper(Generic[_T]): +_EnumT = TypeVar("_EnumT", bound=APIIntEnum) +_ValT = TypeVar("_ValT") + + +class EsphomeEnumMapper(Generic[_EnumT, _ValT]): """Helper class to convert between hass and esphome enum values.""" - def __init__(self, mapping: dict[_T, str]) -> None: + def __init__(self, mapping: dict[_EnumT, _ValT]) -> None: """Construct a EsphomeEnumMapper.""" # Add none mapping - mapping = {None: None, **mapping} - self._mapping = mapping - self._inverse: dict[str, _T] = {v: k for k, v in mapping.items()} + augmented_mapping: dict[_EnumT | None, _ValT | None] = mapping # type: ignore[assignment] + augmented_mapping[None] = None - def from_esphome(self, value: _T | None) -> str | None: + self._mapping = augmented_mapping + self._inverse: dict[_ValT, _EnumT] = {v: k for k, v in mapping.items()} + + @overload + def from_esphome(self, value: _EnumT) -> _ValT: + ... + + @overload + def from_esphome(self, value: _EnumT | None) -> _ValT | None: + ... + + def from_esphome(self, value: _EnumT | None) -> _ValT | None: """Convert from an esphome int representation to a hass string.""" return self._mapping[value] - def from_hass(self, value: str) -> _T: + def from_hass(self, value: _ValT) -> _EnumT: """Convert from a hass string to a esphome int representation.""" return self._inverse[value] -class EsphomeBaseEntity(Entity): +class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" def __init__( @@ -831,6 +882,22 @@ class EsphomeBaseEntity(Entity): ) ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + ( + f"esphome_{self._entry_id}" + f"_update_{self._component_key}_{self._key}" + ), + self._on_state_update, + ) + ) + + @callback + def _on_state_update(self) -> None: + # Behavior can be changed in child classes + self.async_write_ha_state() + @callback def _on_device_update(self) -> None: """Update the entity state when device info has changed.""" @@ -839,7 +906,7 @@ class EsphomeBaseEntity(Entity): # Only update the HA state when the full state arrives # through the next entity state packet. return - self.async_write_ha_state() + self._on_state_update() @property def _entry_id(self) -> str: @@ -850,17 +917,18 @@ class EsphomeBaseEntity(Entity): return self._entry_data.api_version @property - def _static_info(self) -> EntityInfo: + def _static_info(self) -> _InfoT: # Check if value is in info database. Use a single lookup. info = self._entry_data.info[self._component_key].get(self._key) if info is not None: - return info + return cast(_InfoT, info) # This entity is in the removal project and has been removed from .info # already, look in old_info - return self._entry_data.old_info[self._component_key].get(self._key) + return cast(_InfoT, self._entry_data.old_info[self._component_key][self._key]) @property def _device_info(self) -> EsphomeDeviceInfo: + assert self._entry_data.device_info is not None return self._entry_data.device_info @property @@ -868,11 +936,12 @@ class EsphomeBaseEntity(Entity): return self._entry_data.client @property - def _state(self) -> EntityState | None: - try: - return self._entry_data.state[self._component_key][self._key] - except KeyError: - return None + def _state(self) -> _StateT: + return cast(_StateT, self._entry_data.state[self._component_key][self._key]) + + @property + def _has_state(self) -> bool: + return self._key in self._entry_data.state[self._component_key] @property def available(self) -> bool: @@ -909,23 +978,3 @@ class EsphomeBaseEntity(Entity): def should_poll(self) -> bool: """Disable polling.""" return False - - -class EsphomeEntity(EsphomeBaseEntity): - """Define a generic esphome entity.""" - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self.async_write_ha_state, - ) - ) diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py index 28cc47691f5..338f3787090 100644 --- a/homeassistant/components/esphome/binary_sensor.py +++ b/homeassistant/components/esphome/binary_sensor.py @@ -4,11 +4,16 @@ from __future__ import annotations from aioesphomeapi import BinarySensorInfo, BinarySensorState 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 EsphomeEntity, platform_async_setup_entry -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up ESPHome binary sensors based on a config entry.""" await platform_async_setup_entry( hass, @@ -21,17 +26,11 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity): +class EsphomeBinarySensor( + EsphomeEntity[BinarySensorInfo, BinarySensorState], BinarySensorEntity +): """A binary sensor implementation for ESPHome.""" - @property - def _static_info(self) -> BinarySensorInfo: - return super()._static_info - - @property - def _state(self) -> BinarySensorState | None: - return super()._state - @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" @@ -39,7 +38,7 @@ class EsphomeBinarySensor(EsphomeEntity, BinarySensorEntity): # Status binary sensors indicated connected state. # So in their case what's usually _availability_ is now state return self._entry_data.available - if self._state is None: + if not self._has_state: return None if self._state.missing_state: return None diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index f047d5c1bdd..938d78362f7 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -2,20 +2,22 @@ from __future__ import annotations import asyncio +from typing import Any from aioesphomeapi import CameraInfo, CameraState +from aiohttp import web from homeassistant.components import camera from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeBaseEntity, platform_async_setup_entry +from . import EsphomeEntity, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up esphome cameras based on a config entry.""" await platform_async_setup_entry( @@ -29,42 +31,22 @@ async def async_setup_entry( ) -class EsphomeCamera(Camera, EsphomeBaseEntity): +class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]): """A camera implementation for ESPHome.""" - def __init__(self, *args, **kwargs) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize.""" Camera.__init__(self) - EsphomeBaseEntity.__init__(self, *args, **kwargs) + EsphomeEntity.__init__(self, *args, **kwargs) self._image_cond = asyncio.Condition() - @property - def _static_info(self) -> CameraInfo: - return super()._static_info - - @property - def _state(self) -> CameraState | None: - return super()._state - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - - await super().async_added_to_hass() - - self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self._on_state_update, - ) - ) - - async def _on_state_update(self) -> None: + @callback + def _on_state_update(self) -> None: """Notify listeners of new image when update arrives.""" - self.async_write_ha_state() + super()._on_state_update() + self.hass.async_create_task(self._on_state_update_coro()) + + async def _on_state_update_coro(self) -> None: async with self._image_cond: self._image_cond.notify_all() @@ -90,7 +72,9 @@ class EsphomeCamera(Camera, EsphomeBaseEntity): return None return self._state.data[:] - async def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse: """Serve an HTTP MJPEG stream from the camera.""" return await camera.async_get_still_stream( request, self._async_camera_stream_image, camera.DEFAULT_CONTENT_TYPE, 0.0 diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index f7ebccc8434..31d3e5f2320 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -1,6 +1,8 @@ """Support for ESPHome climate devices.""" from __future__ import annotations +from typing import Any, cast + from aioesphomeapi import ( ClimateAction, ClimateFanMode, @@ -56,6 +58,7 @@ from homeassistant.components.climate.const import ( SWING_OFF, SWING_VERTICAL, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -63,6 +66,8 @@ from homeassistant.const import ( PRECISION_WHOLE, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ( EsphomeEntity, @@ -72,7 +77,9 @@ from . import ( ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up ESPHome climate devices based on a config entry.""" await platform_async_setup_entry( hass, @@ -85,7 +92,7 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper( +_CLIMATE_MODES: EsphomeEnumMapper[ClimateMode, str] = EsphomeEnumMapper( { ClimateMode.OFF: HVAC_MODE_OFF, ClimateMode.HEAT_COOL: HVAC_MODE_HEAT_COOL, @@ -96,7 +103,7 @@ _CLIMATE_MODES: EsphomeEnumMapper[ClimateMode] = EsphomeEnumMapper( ClimateMode.AUTO: HVAC_MODE_AUTO, } ) -_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper( +_CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction, str] = EsphomeEnumMapper( { ClimateAction.OFF: CURRENT_HVAC_OFF, ClimateAction.COOLING: CURRENT_HVAC_COOL, @@ -106,7 +113,7 @@ _CLIMATE_ACTIONS: EsphomeEnumMapper[ClimateAction] = EsphomeEnumMapper( ClimateAction.FAN: CURRENT_HVAC_FAN, } ) -_FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper( +_FAN_MODES: EsphomeEnumMapper[ClimateFanMode, str] = EsphomeEnumMapper( { ClimateFanMode.ON: FAN_ON, ClimateFanMode.OFF: FAN_OFF, @@ -119,7 +126,7 @@ _FAN_MODES: EsphomeEnumMapper[ClimateFanMode] = EsphomeEnumMapper( ClimateFanMode.DIFFUSE: FAN_DIFFUSE, } ) -_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper( +_SWING_MODES: EsphomeEnumMapper[ClimateSwingMode, str] = EsphomeEnumMapper( { ClimateSwingMode.OFF: SWING_OFF, ClimateSwingMode.BOTH: SWING_BOTH, @@ -127,7 +134,7 @@ _SWING_MODES: EsphomeEnumMapper[ClimateSwingMode] = EsphomeEnumMapper( ClimateSwingMode.HORIZONTAL: SWING_HORIZONTAL, } ) -_PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper( +_PRESETS: EsphomeEnumMapper[ClimatePreset, str] = EsphomeEnumMapper( { ClimatePreset.NONE: PRESET_NONE, ClimatePreset.HOME: PRESET_HOME, @@ -141,17 +148,13 @@ _PRESETS: EsphomeEnumMapper[ClimatePreset] = EsphomeEnumMapper( ) -class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEntity): """A climate implementation for ESPHome.""" - @property - def _static_info(self) -> ClimateInfo: - return super()._static_info - - @property - def _state(self) -> ClimateState | None: - return super()._state - @property def precision(self) -> float: """Return the precision of the climate device.""" @@ -192,7 +195,7 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): ] + self._static_info.supported_custom_presets @property - def swing_modes(self): + def swing_modes(self) -> list[str]: """Return the list of available swing modes.""" return [ _SWING_MODES.from_esphome(mode) @@ -225,17 +228,14 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): features |= SUPPORT_TARGET_TEMPERATURE if self.preset_modes: features |= SUPPORT_PRESET_MODE - if self._static_info.supported_fan_modes: + if self.fan_modes: features |= SUPPORT_FAN_MODE - if self._static_info.supported_swing_modes: + if self.swing_modes: features |= SUPPORT_SWING_MODE return features - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property - def hvac_mode(self) -> str | None: + def hvac_mode(self) -> str | None: # type: ignore[override] """Return current operation ie. heat, cool, idle.""" return _CLIMATE_MODES.from_esphome(self._state.mode) @@ -286,11 +286,11 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): """Return the highbound target temperature we try to reach.""" return self._state.target_temperature_high - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: float | str) -> None: """Set new target temperature (and operation mode if set).""" - data = {"key": self._static_info.key} + data: dict[str, Any] = {"key": self._static_info.key} if ATTR_HVAC_MODE in kwargs: - data["mode"] = _CLIMATE_MODES.from_hass(kwargs[ATTR_HVAC_MODE]) + data["mode"] = _CLIMATE_MODES.from_hass(cast(str, kwargs[ATTR_HVAC_MODE])) if ATTR_TEMPERATURE in kwargs: data["target_temperature"] = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_LOW in kwargs: @@ -307,21 +307,21 @@ class EsphomeClimateEntity(EsphomeEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set preset mode.""" - kwargs = {} + kwargs: dict[str, Any] = {"key": self._static_info.key} if preset_mode in self._static_info.supported_custom_presets: kwargs["custom_preset"] = preset_mode else: kwargs["preset"] = _PRESETS.from_hass(preset_mode) - await self._client.climate_command(key=self._static_info.key, **kwargs) + await self._client.climate_command(**kwargs) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" - kwargs = {} + kwargs: dict[str, Any] = {"key": self._static_info.key} if fan_mode in self._static_info.supported_custom_fan_modes: kwargs["custom_fan_mode"] = fan_mode else: kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) - await self._client.climate_command(key=self._static_info.key, **kwargs) + await self._client.climate_command(**kwargs) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 38e44b12508..247484ba317 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,15 +2,17 @@ from __future__ import annotations from collections import OrderedDict +from typing import Any -from aioesphomeapi import APIClient, APIConnectionError +from aioesphomeapi import APIClient, APIConnectionError, DeviceInfo import voluptuous as vol from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType from . import DOMAIN, DomainData @@ -20,20 +22,19 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" self._host: str | None = None self._port: int | None = None self._password: str | None = None - async def async_step_user( - self, user_input: ConfigType | None = None, error: str | None = None - ): # pylint: disable=arguments-differ - """Handle a flow initialized by the user.""" + async def _async_step_user_base( + self, user_input: dict[str, Any] | None = None, error: str | None = None + ) -> FlowResult: if user_input is not None: return await self._async_authenticate_or_add(user_input) - fields = OrderedDict() + fields: dict[Any, type] = OrderedDict() fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int @@ -45,26 +46,35 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self._async_step_user_base(user_input=user_input) + @property - def _name(self): + def _name(self) -> str | None: return self.context.get(CONF_NAME) @_name.setter - def _name(self, value): + def _name(self, value: str) -> None: self.context[CONF_NAME] = value self.context["title_placeholders"] = {"name": self._name} - def _set_user_input(self, user_input): + def _set_user_input(self, user_input: dict[str, Any] | None) -> None: if user_input is None: return self._host = user_input[CONF_HOST] self._port = user_input[CONF_PORT] - async def _async_authenticate_or_add(self, user_input): + async def _async_authenticate_or_add( + self, user_input: dict[str, Any] | None + ) -> FlowResult: self._set_user_input(user_input) error, device_info = await self.fetch_device_info() if error is not None: - return await self.async_step_user(error=error) + return await self._async_step_user_base(error=error) + assert device_info is not None self._name = device_info.name # Only show authentication step if device uses password @@ -73,7 +83,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_get_entry() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: return await self._async_authenticate_or_add(None) @@ -81,7 +93,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): step_id="discovery_confirm", description_placeholders={"name": self._name} ) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle zeroconf discovery.""" # Hostname is format: livingroom.local. local_name = discovery_info["hostname"][:-1] @@ -129,7 +143,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() @callback - def _async_get_entry(self): + def _async_get_entry(self) -> FlowResult: + assert self._name is not None return self.async_create_entry( title=self._name, data={ @@ -140,7 +155,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_authenticate(self, user_input=None, error=None): + async def async_step_authenticate( + self, user_input: dict[str, Any] | None = None, error: str | None = None + ) -> FlowResult: """Handle getting password for authentication.""" if user_input is not None: self._password = user_input[CONF_PASSWORD] @@ -160,9 +177,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def fetch_device_info(self): + async def fetch_device_info(self) -> tuple[str | None, DeviceInfo | None]: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) + assert self._host is not None + assert self._port is not None cli = APIClient( self.hass.loop, self._host, @@ -183,9 +202,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return None, device_info - async def try_login(self): + async def try_login(self) -> str | None: """Try logging in to device and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) + assert self._host is not None + assert self._port is not None cli = APIClient( self.hass.loop, self._host, diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 3064f827d7f..e055ffc5d03 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -1,6 +1,8 @@ """Support for ESPHome covers.""" from __future__ import annotations +from typing import Any + from aioesphomeapi import CoverInfo, CoverOperation, CoverState from homeassistant.components.cover import ( @@ -17,12 +19,13 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome covers based on a config entry.""" await platform_async_setup_entry( @@ -36,12 +39,12 @@ async def async_setup_entry( ) -class EsphomeCover(EsphomeEntity, CoverEntity): - """A cover implementation for ESPHome.""" +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method - @property - def _static_info(self) -> CoverInfo: - return super()._static_info + +class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): + """A cover implementation for ESPHome.""" @property def supported_features(self) -> int: @@ -63,13 +66,6 @@ class EsphomeCover(EsphomeEntity, CoverEntity): """Return true if we do optimistic updates.""" return self._static_info.assumed_state - @property - def _state(self) -> CoverState | None: - return super()._state - - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" @@ -94,39 +90,39 @@ class EsphomeCover(EsphomeEntity, CoverEntity): return round(self._state.position * 100.0) @esphome_state_property - def current_cover_tilt_position(self) -> float | None: + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. 0 is closed, 100 is open.""" if not self._static_info.supports_tilt: return None - return self._state.tilt * 100.0 + return round(self._state.tilt * 100.0) - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._client.cover_command(key=self._static_info.key, position=1.0) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._client.cover_command(key=self._static_info.key, position=0.0) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._client.cover_command(key=self._static_info.key, stop=True) - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: int) -> None: """Move the cover to a specific position.""" await self._client.cover_command( key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 ) - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=1.0) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=0.0) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: int) -> None: """Move the cover tilt to a specific position.""" await self._client.cover_command( key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100 diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index f60d7cfefb5..2b926b9b270 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Callable +from typing import Any, Callable, cast from aioesphomeapi import ( COMPONENT_TYPE_TO_INFO, + APIClient, APIVersion, BinarySensorInfo, CameraInfo, @@ -18,6 +19,7 @@ from aioesphomeapi import ( FanInfo, LightInfo, NumberInfo, + SelectInfo, SensorInfo, SwitchInfo, TextSensorInfo, @@ -29,13 +31,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store -if TYPE_CHECKING: - from . import APIClient - SAVE_DELAY = 120 # Mapping from ESPHome info type to HA platform -INFO_TYPE_TO_PLATFORM = { +INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { BinarySensorInfo: "binary_sensor", CameraInfo: "camera", ClimateInfo: "climate", @@ -43,6 +42,7 @@ INFO_TYPE_TO_PLATFORM = { FanInfo: "fan", LightInfo: "light", NumberInfo: "number", + SelectInfo: "select", SensorInfo: "sensor", SwitchInfo: "switch", TextSensorInfo: "sensor", @@ -56,14 +56,14 @@ class RuntimeEntryData: entry_id: str client: APIClient store: Store - state: dict[str, dict[str, Any]] = field(default_factory=dict) - info: dict[str, dict[str, Any]] = field(default_factory=dict) + state: dict[str, dict[int, EntityState]] = field(default_factory=dict) + info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects # This is necessary for when an entity is being removed. HA requires # some static info to be accessible during removal (unique_id, maybe others) # If an entity can't find anything in the info array, it will look for info here. - old_info: dict[str, dict[str, Any]] = field(default_factory=dict) + old_info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) services: dict[int, UserService] = field(default_factory=dict) available: bool = False @@ -73,7 +73,7 @@ class RuntimeEntryData: disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) loaded_platforms: set[str] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - _storage_contents: dict | None = None + _storage_contents: dict[str, Any] | None = None @callback def async_update_entity( @@ -93,7 +93,7 @@ class RuntimeEntryData: async def _ensure_platforms_loaded( self, hass: HomeAssistant, entry: ConfigEntry, platforms: set[str] - ): + ) -> None: async with self.platform_load_lock: needed = platforms - self.loaded_platforms tasks = [] @@ -139,6 +139,7 @@ class RuntimeEntryData: restored = await self.store.async_load() if restored is None: return [], [] + restored = cast("dict[str, Any]", restored) self._storage_contents = restored.copy() self.device_info = DeviceInfo.from_dict(restored.pop("device_info")) @@ -157,7 +158,9 @@ class RuntimeEntryData: async def async_save_to_store(self) -> None: """Generate dynamic data to store and save it to the filesystem.""" - store_data = { + if self.device_info is None: + raise ValueError("device_info is not set yet") + store_data: dict[str, Any] = { "device_info": self.device_info.to_dict(), "services": [], "api_version": self.api_version.to_dict(), @@ -171,7 +174,7 @@ class RuntimeEntryData: if store_data == self._storage_contents: return - def _memorized_storage(): + def _memorized_storage() -> dict[str, Any]: self._storage_contents = store_data return store_data diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index e02958d5885..6abce0914cb 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import Any from aioesphomeapi import FanDirection, FanInfo, FanSpeed, FanState @@ -15,6 +16,7 @@ from homeassistant.components.fan import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -33,7 +35,7 @@ ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH] async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome fans based on a config entry.""" await platform_async_setup_entry( @@ -47,7 +49,7 @@ async def async_setup_entry( ) -_FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection] = EsphomeEnumMapper( +_FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection, str] = EsphomeEnumMapper( { FanDirection.FORWARD: DIRECTION_FORWARD, FanDirection.REVERSE: DIRECTION_REVERSE, @@ -55,29 +57,25 @@ _FAN_DIRECTIONS: EsphomeEnumMapper[FanDirection] = EsphomeEnumMapper( ) -class EsphomeFan(EsphomeEntity, FanEntity): +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): """A fan implementation for ESPHome.""" - @property - def _static_info(self) -> FanInfo: - return super()._static_info - - @property - def _state(self) -> FanState | None: - return super()._state - @property def _supports_speed_levels(self) -> bool: api_version = self._api_version return api_version.major == 1 and api_version.minor > 3 - async def async_set_percentage(self, percentage: int) -> None: + async def async_set_percentage(self, percentage: int | None) -> None: """Set the speed percentage of the fan.""" if percentage == 0: await self.async_turn_off() return - data = {"key": self._static_info.key, "state": True} + data: dict[str, Any] = {"key": self._static_info.key, "state": True} if percentage is not None: if self._supports_speed_levels: data["speed_level"] = math.ceil( @@ -97,12 +95,12 @@ class EsphomeFan(EsphomeEntity, FanEntity): speed: str | None = None, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self._client.fan_command(key=self._static_info.key, state=False) @@ -112,17 +110,14 @@ class EsphomeFan(EsphomeEntity, FanEntity): key=self._static_info.key, oscillating=oscillating ) - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" await self._client.fan_command( key=self._static_info.key, direction=_FAN_DIRECTIONS.from_hass(direction) ) - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method - @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] """Return true if the entity is on.""" return self._state.state @@ -134,7 +129,7 @@ class EsphomeFan(EsphomeEntity, FanEntity): if not self._supports_speed_levels: return ordered_list_item_to_percentage( - ORDERED_NAMED_FAN_SPEEDS, self._state.speed + ORDERED_NAMED_FAN_SPEEDS, self._state.speed # type: ignore[misc] ) return ranged_value_to_percentage( diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index d1f567c3c8e..b89a75ab76a 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -1,38 +1,51 @@ """Support for ESPHome lights.""" from __future__ import annotations -from aioesphomeapi import LightInfo, LightState +from typing import Any, cast + +from aioesphomeapi import APIVersion, LightColorMode, LightInfo, LightState from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ATTR_TRANSITION, - ATTR_WHITE_VALUE, + ATTR_WHITE, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_ONOFF, + COLOR_MODE_RGB, + COLOR_MODE_RGBW, + COLOR_MODE_RGBWW, + COLOR_MODE_UNKNOWN, + COLOR_MODE_WHITE, FLASH_LONG, FLASH_SHORT, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, LightEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.util.color as color_util +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome lights based on a config entry.""" await platform_async_setup_entry( @@ -46,49 +59,121 @@ async def async_setup_entry( ) -class EsphomeLight(EsphomeEntity, LightEntity): - """A switch implementation for ESPHome.""" +_COLOR_MODES: EsphomeEnumMapper[LightColorMode, str] = EsphomeEnumMapper( + { + LightColorMode.UNKNOWN: COLOR_MODE_UNKNOWN, + LightColorMode.ON_OFF: COLOR_MODE_ONOFF, + LightColorMode.BRIGHTNESS: COLOR_MODE_BRIGHTNESS, + LightColorMode.WHITE: COLOR_MODE_WHITE, + LightColorMode.COLOR_TEMPERATURE: COLOR_MODE_COLOR_TEMP, + LightColorMode.COLD_WARM_WHITE: COLOR_MODE_COLOR_TEMP, + LightColorMode.RGB: COLOR_MODE_RGB, + LightColorMode.RGB_WHITE: COLOR_MODE_RGBW, + LightColorMode.RGB_COLOR_TEMPERATURE: COLOR_MODE_RGBWW, + LightColorMode.RGB_COLD_WARM_WHITE: COLOR_MODE_RGBWW, + } +) + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): + """A light implementation for ESPHome.""" @property - def _static_info(self) -> LightInfo: - return super()._static_info - - @property - def _state(self) -> LightState | None: - return super()._state - - # https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property - # pylint: disable=invalid-overridden-method + def _supports_color_mode(self) -> bool: + """Return whether the client supports the new color mode system natively.""" + return self._api_version >= APIVersion(1, 6) @esphome_state_property - def is_on(self) -> bool | None: - """Return true if the switch is on.""" + def is_on(self) -> bool | None: # type: ignore[override] + """Return true if the light is on.""" return self._state.state - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - data = {"key": self._static_info.key, "state": True} - if ATTR_HS_COLOR in kwargs: - hue, sat = kwargs[ATTR_HS_COLOR] - red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100) - data["rgb"] = (red / 255, green / 255, blue / 255) - if ATTR_FLASH in kwargs: - data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] - if ATTR_TRANSITION in kwargs: - data["transition_length"] = kwargs[ATTR_TRANSITION] - if ATTR_BRIGHTNESS in kwargs: - data["brightness"] = kwargs[ATTR_BRIGHTNESS] / 255 - if ATTR_COLOR_TEMP in kwargs: - data["color_temperature"] = kwargs[ATTR_COLOR_TEMP] - if ATTR_EFFECT in kwargs: - data["effect"] = kwargs[ATTR_EFFECT] - if ATTR_WHITE_VALUE in kwargs: - data["white"] = kwargs[ATTR_WHITE_VALUE] / 255 + data: dict[str, Any] = {"key": self._static_info.key, "state": True} + # rgb/brightness input is in range 0-255, but esphome uses 0-1 + + if (brightness_ha := kwargs.get(ATTR_BRIGHTNESS)) is not None: + data["brightness"] = brightness_ha / 255 + + if (rgb_ha := kwargs.get(ATTR_RGB_COLOR)) is not None: + rgb = tuple(x / 255 for x in rgb_ha) + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = LightColorMode.RGB + + if (rgbw_ha := kwargs.get(ATTR_RGBW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, w = tuple(x / 255 for x in rgbw_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + data["white"] = w + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = LightColorMode.RGB_WHITE + + if (rgbww_ha := kwargs.get(ATTR_RGBWW_COLOR)) is not None: + # pylint: disable=invalid-name + *rgb, cw, ww = tuple(x / 255 for x in rgbww_ha) # type: ignore[assignment] + color_bri = max(rgb) + # normalize rgb + data["rgb"] = tuple(x / (color_bri or 1) for x in rgb) + modes = self._native_supported_color_modes + if ( + self._supports_color_mode + and LightColorMode.RGB_COLD_WARM_WHITE in modes + ): + data["cold_white"] = cw + data["warm_white"] = ww + target_mode = LightColorMode.RGB_COLD_WARM_WHITE + else: + # need to convert cw+ww part to white+color_temp + white = data["white"] = max(cw, ww) + if white != 0: + min_ct = self.min_mireds + max_ct = self.max_mireds + ct_ratio = ww / (cw + ww) + data["color_temperature"] = min_ct + ct_ratio * (max_ct - min_ct) + target_mode = LightColorMode.RGB_COLOR_TEMPERATURE + + if self._supports_color_mode: + data["color_brightness"] = color_bri + data["color_mode"] = target_mode + + if (flash := kwargs.get(ATTR_FLASH)) is not None: + data["flash_length"] = FLASH_LENGTHS[flash] + + if (transition := kwargs.get(ATTR_TRANSITION)) is not None: + data["transition_length"] = transition + + if (color_temp := kwargs.get(ATTR_COLOR_TEMP)) is not None: + data["color_temperature"] = color_temp + if self._supports_color_mode: + data["color_mode"] = LightColorMode.COLOR_TEMPERATURE + + if (effect := kwargs.get(ATTR_EFFECT)) is not None: + data["effect"] = effect + + if (white_ha := kwargs.get(ATTR_WHITE)) is not None: + # ESPHome multiplies brightness and white together for final brightness + # HA only sends `white` in turn_on, and reads total brightness through brightness property + data["brightness"] = white_ha / 255 + data["white"] = 1.0 + data["color_mode"] = LightColorMode.WHITE + await self._client.light_command(**data) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - data = {"key": self._static_info.key, "state": False} + data: dict[str, Any] = {"key": self._static_info.key, "state": False} if ATTR_FLASH in kwargs: data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: @@ -101,55 +186,110 @@ class EsphomeLight(EsphomeEntity, LightEntity): return round(self._state.brightness * 255) @esphome_state_property - def hs_color(self) -> tuple[float, float] | None: - """Return the hue and saturation color value [float, float].""" - return color_util.color_RGB_to_hs( - self._state.red * 255, self._state.green * 255, self._state.blue * 255 + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if not self._supports_color_mode: + supported = self.supported_color_modes + if not supported: + return None + return next(iter(supported)) + + return _COLOR_MODES.from_esphome(self._state.color_mode) + + @esphome_state_property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the rgb color value [int, int, int].""" + if not self._supports_color_mode: + return ( + round(self._state.red * 255), + round(self._state.green * 255), + round(self._state.blue * 255), + ) + + return ( + round(self._state.red * self._state.color_brightness * 255), + round(self._state.green * self._state.color_brightness * 255), + round(self._state.blue * self._state.color_brightness * 255), ) @esphome_state_property - def color_temp(self) -> float | None: - """Return the CT color value in mireds.""" - return self._state.color_temperature + def rgbw_color(self) -> tuple[int, int, int, int] | None: + """Return the rgbw color value [int, int, int, int].""" + white = round(self._state.white * 255) + rgb = cast("tuple[int, int, int]", self.rgb_color) + return (*rgb, white) @esphome_state_property - def white_value(self) -> int | None: - """Return the white value of this light between 0..255.""" - return round(self._state.white * 255) + def rgbww_color(self) -> tuple[int, int, int, int, int] | None: + """Return the rgbww color value [int, int, int, int, int].""" + rgb = cast("tuple[int, int, int]", self.rgb_color) + if ( + not self._supports_color_mode + or self._state.color_mode != LightColorMode.RGB_COLD_WARM_WHITE + ): + # Try to reverse white + color temp to cwww + min_ct = self._static_info.min_mireds + max_ct = self._static_info.max_mireds + color_temp = self._state.color_temperature + white = self._state.white + + ww_frac = (color_temp - min_ct) / (max_ct - min_ct) + cw_frac = 1 - ww_frac + + return ( + *rgb, + round(white * cw_frac / max(cw_frac, ww_frac) * 255), + round(white * ww_frac / max(cw_frac, ww_frac) * 255), + ) + return ( + *rgb, + round(self._state.cold_white * 255), + round(self._state.warm_white * 255), + ) + + @esphome_state_property + def color_temp(self) -> float | None: # type: ignore[override] + """Return the CT color value in mireds.""" + return self._state.color_temperature @esphome_state_property def effect(self) -> str | None: """Return the current effect.""" return self._state.effect + @property + def _native_supported_color_modes(self) -> list[LightColorMode]: + return self._static_info.supported_color_modes_compat(self._api_version) + @property def supported_features(self) -> int: """Flag supported features.""" flags = SUPPORT_FLASH - if self._static_info.supports_brightness: - flags |= SUPPORT_BRIGHTNESS + + # All color modes except UNKNOWN,ON_OFF support transition + modes = self._native_supported_color_modes + if any(m not in (LightColorMode.UNKNOWN, LightColorMode.ON_OFF) for m in modes): flags |= SUPPORT_TRANSITION - if self._static_info.supports_rgb: - flags |= SUPPORT_COLOR - if self._static_info.supports_white_value: - flags |= SUPPORT_WHITE_VALUE - if self._static_info.supports_color_temperature: - flags |= SUPPORT_COLOR_TEMP if self._static_info.effects: flags |= SUPPORT_EFFECT return flags + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + return set(map(_COLOR_MODES.from_esphome, self._native_supported_color_modes)) + @property def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._static_info.effects @property - def min_mireds(self) -> float: + def min_mireds(self) -> float: # type: ignore[override] """Return the coldest color_temp that this light supports.""" return self._static_info.min_mireds @property - def max_mireds(self) -> float: + def max_mireds(self) -> float: # type: ignore[override] """Return the warmest color_temp that this light supports.""" return self._static_info.max_mireds diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d8a22534001..22fa33091fd 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==5.0.1"], + "requirements": ["aioesphomeapi==6.0.1"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 08b31e91b79..1a90cdbeb24 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import cast from aioesphomeapi import NumberInfo, NumberState import voluptuous as vol @@ -38,23 +39,15 @@ async def async_setup_entry( # pylint: disable=invalid-overridden-method -class EsphomeNumber(EsphomeEntity, NumberEntity): +class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """A number implementation for esphome.""" - @property - def _static_info(self) -> NumberInfo: - return super()._static_info - - @property - def _state(self) -> NumberState | None: - return super()._state - @property def icon(self) -> str | None: """Return the icon.""" if not self._static_info.icon: return None - return ICON_SCHEMA(self._static_info.icon) + return cast(str, ICON_SCHEMA(self._static_info.icon)) @property def min_value(self) -> float: @@ -72,7 +65,7 @@ class EsphomeNumber(EsphomeEntity, NumberEntity): return super()._static_info.step @esphome_state_property - def value(self) -> float: + def value(self) -> float | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py new file mode 100644 index 00000000000..6ba6ba4c594 --- /dev/null +++ b/homeassistant/components/esphome/select.py @@ -0,0 +1,65 @@ +"""Support for esphome selects.""" +from __future__ import annotations + +from typing import cast + +from aioesphomeapi import SelectInfo, SelectState +import voluptuous as vol + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry + +ICON_SCHEMA = vol.Schema(cv.icon) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome selects based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + component_key="select", + info_type=SelectInfo, + entity_type=EsphomeSelect, + state_type=SelectState, + ) + + +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): + """A select implementation for esphome.""" + + @property + def icon(self) -> str | None: + """Return the icon.""" + if not self._static_info.icon: + return None + return cast(str, ICON_SCHEMA(self._static_info.icon)) + + @property + def options(self) -> list[str]: + """Return a set of selectable options.""" + return self._static_info.options + + @esphome_state_property + def current_option(self) -> str | None: + """Return the state of the entity.""" + if self._state.missing_state: + return None + return self._state.state + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self._client.select_command(self._static_info.key, option) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index d3dce2dea1b..6a2b51498f0 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -1,7 +1,10 @@ """Support for esphome sensors.""" from __future__ import annotations +from contextlib import suppress +from datetime import datetime import math +from typing import cast from aioesphomeapi import ( SensorInfo, @@ -10,6 +13,7 @@ from aioesphomeapi import ( TextSensorInfo, TextSensorState, ) +from aioesphomeapi.model import LastResetType import voluptuous as vol from homeassistant.components.sensor import ( @@ -19,8 +23,10 @@ from homeassistant.components.sensor import ( SensorEntity, ) from homeassistant.config_entries import ConfigEntry -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.restore_state import RestoreEntity from homeassistant.util import dt from . import ( @@ -34,7 +40,7 @@ ICON_SCHEMA = vol.Schema(cv.icon) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up esphome sensors based on a config entry.""" await platform_async_setup_entry( @@ -61,7 +67,7 @@ async def async_setup_entry( # pylint: disable=invalid-overridden-method -_STATE_CLASSES: EsphomeEnumMapper[SensorStateClass] = EsphomeEnumMapper( +_STATE_CLASSES: EsphomeEnumMapper[SensorStateClass, str | None] = EsphomeEnumMapper( { SensorStateClass.NONE: None, SensorStateClass.MEASUREMENT: STATE_CLASS_MEASUREMENT, @@ -69,23 +75,85 @@ _STATE_CLASSES: EsphomeEnumMapper[SensorStateClass] = EsphomeEnumMapper( ) -class EsphomeSensor(EsphomeEntity, SensorEntity): +class EsphomeSensor( + EsphomeEntity[SensorInfo, SensorState], SensorEntity, RestoreEntity +): """A sensor implementation for esphome.""" - @property - def _static_info(self) -> SensorInfo: - return super()._static_info + _old_state: float | None = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if self._static_info.last_reset_type != LastResetType.AUTO: + return + + # Logic to restore old state for last_reset_type AUTO: + last_state = await self.async_get_last_state() + if last_state is None: + return + + if "last_reset" in last_state.attributes: + self._attr_last_reset = dt.as_utc( + datetime.fromisoformat(last_state.attributes["last_reset"]) + ) + + with suppress(ValueError): + self._old_state = float(last_state.state) + + @callback + def _on_state_update(self) -> None: + """Check last_reset when new state arrives.""" + if self._static_info.last_reset_type == LastResetType.NEVER: + self._attr_last_reset = dt.utc_from_timestamp(0) + + if self._static_info.last_reset_type != LastResetType.AUTO: + super()._on_state_update() + return + + # Last reset type AUTO logic for the last_reset property + # In this mode we automatically determine if an accumulator reset + # has taken place. + # We compare the last valid value (_old_state) with the new one. + # If the value has reset to 0 or has significantly reduced we say + # it has reset. + new_state: float | None = None + state = cast("str | None", self.state) + if state is not None: + with suppress(ValueError): + new_state = float(state) + + did_reset = False + if new_state is None: + # New state is not a float - we'll detect the reset once we get valid data again + did_reset = False + elif self._old_state is None: + # First measurement we ever got for this sensor, always a reset + did_reset = True + elif new_state == 0: + # don't set reset if both old and new are 0 + # we would already have detected the reset on the last state + did_reset = self._old_state != 0 + elif new_state < self._old_state: + did_reset = True + + # Set last_reset to now if we detected a reset + if did_reset: + self._attr_last_reset = dt.utcnow() + + if new_state is not None: + # Only write to old_state if the new one contains actual data + self._old_state = new_state + + super()._on_state_update() @property - def _state(self) -> SensorState | None: - return super()._state - - @property - def icon(self) -> str: + def icon(self) -> str | None: """Return the icon.""" if not self._static_info.icon or self._static_info.device_class: return None - return ICON_SCHEMA(self._static_info.icon) + return cast(str, ICON_SCHEMA(self._static_info.icon)) @property def force_update(self) -> bool: @@ -104,14 +172,14 @@ class EsphomeSensor(EsphomeEntity, SensorEntity): return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" if not self._static_info.unit_of_measurement: return None return self._static_info.unit_of_measurement @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if self._static_info.device_class not in DEVICE_CLASSES: return None @@ -125,17 +193,9 @@ class EsphomeSensor(EsphomeEntity, SensorEntity): return _STATE_CLASSES.from_esphome(self._static_info.state_class) -class EsphomeTextSensor(EsphomeEntity, SensorEntity): +class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): """A text sensor implementation for ESPHome.""" - @property - def _static_info(self) -> TextSensorInfo: - return super()._static_info - - @property - def _state(self) -> TextSensorState | None: - return super()._state - @property def icon(self) -> str: """Return the icon.""" diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 341068b05ad..218cd3905b0 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -1,17 +1,20 @@ """Support for ESPHome switches.""" from __future__ import annotations +from typing import Any + from aioesphomeapi import SwitchInfo, SwitchState 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 EsphomeEntity, esphome_state_property, platform_async_setup_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up ESPHome switches based on a config entry.""" await platform_async_setup_entry( @@ -25,17 +28,13 @@ async def async_setup_entry( ) -class EsphomeSwitch(EsphomeEntity, SwitchEntity): +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + +class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): """A switch implementation for ESPHome.""" - @property - def _static_info(self) -> SwitchInfo: - return super()._static_info - - @property - def _state(self) -> SwitchState | None: - return super()._state - @property def icon(self) -> str: """Return the icon.""" @@ -46,17 +45,15 @@ class EsphomeSwitch(EsphomeEntity, SwitchEntity): """Return true if we do optimistic updates.""" return self._static_info.assumed_state - # https://github.com/PyCQA/pylint/issues/3150 for @esphome_state_property - # pylint: disable=invalid-overridden-method @esphome_state_property - def is_on(self) -> bool | None: + def is_on(self) -> bool | None: # type: ignore[override] """Return true if the switch is on.""" return self._state.state - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self._client.switch_command(self._static_info.key, True) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._client.switch_command(self._static_info.key, False) diff --git a/homeassistant/components/esphome/translations/de.json b/homeassistant/components/esphome/translations/de.json index c82afc78851..8084ef26f0e 100644 --- a/homeassistant/components/esphome/translations/de.json +++ b/homeassistant/components/esphome/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "ESP ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" }, "error": { diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py index 1fa2edbf2e8..1b10cc39fe1 100644 --- a/homeassistant/components/etherscan/sensor.py +++ b/homeassistant/components/etherscan/sensor.py @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if token: token = token.upper() if not name: - name = "%s Balance" % token + name = f"{token} Balance" if not name: name = "ETH Balance" diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 4084045b1fb..045a742485b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -653,10 +653,10 @@ class EvoChild(EvoDevice): this_sp_day = -1 if sp_idx == -1 else 0 next_sp_day = 1 if sp_idx + 1 == len(day["Switchpoints"]) else 0 - for key, offset, idx in [ + for key, offset, idx in ( ("this", this_sp_day, sp_idx), ("next", next_sp_day, (sp_idx + 1) * (1 - next_sp_day)), - ]: + ): sp_date = (day_time + timedelta(days=offset)).strftime("%Y-%m-%d") day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 19dd5121d69..d2d98aa04cb 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -1,10 +1,10 @@ """Support for Ezviz camera.""" -from datetime import timedelta import logging from pyezviz.client import EzvizClient from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, CONF_TIMEOUT, @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_URL, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import ( @@ -28,8 +29,6 @@ from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - PLATFORMS = [ "binary_sensor", "camera", @@ -38,17 +37,16 @@ PLATFORMS = [ ] -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ezviz from a config entry.""" hass.data.setdefault(DOMAIN, {}) if not entry.options: options = { - CONF_FFMPEG_ARGUMENTS: entry.data.get( - CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS - ), - CONF_TIMEOUT: entry.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT), + CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS, + CONF_TIMEOUT: DEFAULT_TIMEOUT, } + hass.config_entries.async_update_entry(entry, options=options) if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: @@ -70,7 +68,9 @@ async def async_setup_entry(hass, entry): _LOGGER.error("Unable to connect to Ezviz service: %s", str(error)) raise ConfigEntryNotReady from error - coordinator = EzvizDataUpdateCoordinator(hass, api=ezviz_client) + coordinator = EzvizDataUpdateCoordinator( + hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT] + ) await coordinator.async_refresh() if not coordinator.last_update_success: @@ -87,7 +87,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA: @@ -100,12 +100,12 @@ async def async_unload_entry(hass, entry): return unload_ok -async def _async_update_listener(hass, entry): +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def _get_ezviz_client_instance(entry): +def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient: """Initialize a new instance of EzvizClientApi.""" ezviz_client = EzvizClient( entry.data[CONF_USERNAME], diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index abfe06d8daf..bc343f06065 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -4,16 +4,25 @@ import logging from pyezviz.constants import BinarySensorType from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER +from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] sensors = [] for idx, camera in enumerate(coordinator.data): @@ -34,7 +43,15 @@ async def async_setup_entry(hass, entry, async_add_entities): class EzvizBinarySensor(CoordinatorEntity, BinarySensorEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, name, sensor_type_name): + coordinator: EzvizDataUpdateCoordinator + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + idx: int, + name: str, + sensor_type_name: str, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._idx = idx @@ -45,22 +62,22 @@ class EzvizBinarySensor(CoordinatorEntity, BinarySensorEntity): self._serial = self.coordinator.data[self._idx]["serial"] @property - def name(self): + def name(self) -> str: """Return the name of the Ezviz sensor.""" - return self._sensor_name + return self._name @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the sensor.""" return self.coordinator.data[self._idx][self._name] @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of this sensor.""" return f"{self._serial}_{self._sensor_name}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" return { "identifiers": {(DOMAIN, self._serial)}, @@ -71,6 +88,6 @@ class EzvizBinarySensor(CoordinatorEntity, BinarySensorEntity): } @property - def device_class(self): + def device_class(self) -> str: """Device class for the sensor.""" return self.sensor_type_name diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index b09e5cdd901..76fbaee3757 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -1,4 +1,6 @@ """Support ezviz camera devices.""" +from __future__ import annotations + import asyncio import logging @@ -8,10 +10,17 @@ import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.config_entries import SOURCE_DISCOVERY, SOURCE_IGNORE, SOURCE_IMPORT +from homeassistant.config_entries import ( + SOURCE_DISCOVERY, + SOURCE_IGNORE, + SOURCE_IMPORT, + ConfigEntry, +) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( @@ -39,6 +48,7 @@ from .const import ( SERVICE_PTZ, SERVICE_WAKE_DEVICE, ) +from .coordinator import EzvizDataUpdateCoordinator CAMERA_SCHEMA = vol.Schema( {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} @@ -55,7 +65,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( _LOGGER = logging.getLogger(__name__) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: entity_platform.AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up a Ezviz IP Camera from platform config.""" _LOGGER.warning( "Loading ezviz via platform config is deprecated, it will be automatically imported. Please remove it afterwards" @@ -91,10 +106,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: """Set up Ezviz cameras based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] camera_config_entries = hass.config_entries.async_entries(DOMAIN) camera_entities = [] @@ -169,7 +190,7 @@ async def async_setup_entry(hass, entry, async_add_entities): async_add_entities(camera_entities) - platform = entity_platform.current_platform.get() + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_PTZ, @@ -210,20 +231,22 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): +class EzvizCamera(CoordinatorEntity, Camera): """An implementation of a Ezviz security camera.""" + coordinator: EzvizDataUpdateCoordinator + def __init__( self, - hass, - coordinator, - idx, - camera_username, - camera_password, - camera_rtsp_stream, - local_rtsp_port, - ffmpeg_arguments, - ): + hass: HomeAssistant, + coordinator: EzvizDataUpdateCoordinator, + idx: int, + camera_username: str, + camera_password: str, + camera_rtsp_stream: str | None, + local_rtsp_port: int | None, + ffmpeg_arguments: str | None, + ) -> None: """Initialize a Ezviz security camera.""" super().__init__(coordinator) Camera.__init__(self) @@ -240,51 +263,48 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): self._local_ip = self.coordinator.data[self._idx]["local_ip"] @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" - if self.coordinator.data[self._idx]["status"] == 2: - return False - - return True + return self.coordinator.data[self._idx]["status"] != 2 @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" if self._rtsp_stream: return SUPPORT_STREAM return 0 @property - def name(self): + def name(self) -> str: """Return the name of this device.""" return self._name @property - def model(self): + def model(self) -> str: """Return the model of this device.""" return self.coordinator.data[self._idx]["device_sub_category"] @property - def brand(self): + def brand(self) -> str: """Return the manufacturer of this device.""" return MANUFACTURER @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return bool(self.coordinator.data[self._idx]["status"]) @property - def is_recording(self): + def is_recording(self) -> bool: """Return true if the device is recording.""" return self.coordinator.data[self._idx]["alarm_notify"] @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Camera Motion Detection Status.""" return self.coordinator.data[self._idx]["alarm_notify"] - def enable_motion_detection(self): + def enable_motion_detection(self) -> None: """Enable motion detection in camera.""" try: self.coordinator.ezviz_client.set_camera_defence(self._serial, 1) @@ -292,7 +312,7 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): except InvalidHost as err: raise InvalidHost("Error enabling motion detection") from err - def disable_motion_detection(self): + def disable_motion_detection(self) -> None: """Disable motion detection.""" try: self.coordinator.ezviz_client.set_camera_defence(self._serial, 0) @@ -301,11 +321,11 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): raise InvalidHost("Error disabling motion detection") from err @property - def unique_id(self): + def unique_id(self) -> str: """Return the name of this camera.""" return self._serial - async def async_camera_image(self): + async def async_camera_image(self) -> bytes | None: """Return a frame from the camera stream.""" ffmpeg = ImageFrame(self._ffmpeg.binary) @@ -315,7 +335,7 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): return image @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" return { "identifiers": {(DOMAIN, self._serial)}, @@ -325,7 +345,7 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): "sw_version": self.coordinator.data[self._idx]["version"], } - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the stream source.""" local_ip = self.coordinator.data[self._idx]["local_ip"] if self._local_rtsp_port: @@ -340,9 +360,8 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): return rtsp_stream_source return None - def perform_ptz(self, direction, speed): + def perform_ptz(self, direction: str, speed: int) -> None: """Perform a PTZ action on the camera.""" - _LOGGER.debug("PTZ action '%s' on %s", direction, self._name) try: self.coordinator.ezviz_client.ptz_control( str(direction).upper(), self._serial, "START", speed @@ -354,21 +373,21 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): except HTTPError as err: raise HTTPError("Cannot perform PTZ") from err - def perform_sound_alarm(self, enable): + def perform_sound_alarm(self, enable: int) -> None: """Sound the alarm on a camera.""" try: self.coordinator.ezviz_client.sound_alarm(self._serial, enable) except HTTPError as err: raise HTTPError("Cannot sound alarm") from err - def perform_wake_device(self): + def perform_wake_device(self) -> None: """Basically wakes the camera by querying the device.""" try: self.coordinator.ezviz_client.get_detection_sensibility(self._serial) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot wake device") from err - def perform_alarm_sound(self, level): + def perform_alarm_sound(self, level: int) -> None: """Enable/Disable movement sound alarm.""" try: self.coordinator.ezviz_client.alarm_sound(self._serial, level, 1) @@ -377,7 +396,9 @@ class EzvizCamera(CoordinatorEntity, Camera, RestoreEntity): "Cannot set alarm sound level for on movement detected" ) from err - def perform_set_alarm_detection_sensibility(self, level, type_value): + def perform_set_alarm_detection_sensibility( + self, level: int, type_value: int + ) -> None: """Set camera detection sensibility level service.""" try: self.coordinator.ezviz_client.detection_sensibility( diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py index e3e2cae712c..ec1471d8bc4 100644 --- a/homeassistant/components/ezviz/const.py +++ b/homeassistant/components/ezviz/const.py @@ -34,7 +34,7 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility" EU_URL = "apiieu.ezvizlife.com" RUSSIA_URL = "apirus.ezvizru.com" DEFAULT_CAMERA_USERNAME = "admin" -DEFAULT_RTSP_PORT = "554" +DEFAULT_RTSP_PORT = 554 DEFAULT_TIMEOUT = 25 DEFAULT_FFMPEG_ARGUMENTS = "" diff --git a/homeassistant/components/ezviz/coordinator.py b/homeassistant/components/ezviz/coordinator.py index ad755edce12..8729aa4cf21 100644 --- a/homeassistant/components/ezviz/coordinator.py +++ b/homeassistant/components/ezviz/coordinator.py @@ -3,8 +3,10 @@ from datetime import timedelta import logging from async_timeout import timeout +from pyezviz.client import EzvizClient from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -15,23 +17,24 @@ _LOGGER = logging.getLogger(__name__) class EzvizDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Ezviz data.""" - def __init__(self, hass, *, api): + def __init__( + self, hass: HomeAssistant, *, api: EzvizClient, api_timeout: int + ) -> None: """Initialize global Ezviz data updater.""" self.ezviz_client = api + self._api_timeout = api_timeout update_interval = timedelta(seconds=30) super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - def _update_data(self): + def _update_data(self) -> dict: """Fetch data from Ezviz via camera load function.""" - cameras = self.ezviz_client.load_cameras() + return self.ezviz_client.load_cameras() - return cameras - - async def _async_update_data(self): + async def _async_update_data(self) -> dict: """Fetch data from Ezviz.""" try: - async with timeout(35): + async with timeout(self._api_timeout): return await self.hass.async_add_executor_job(self._update_data) except (InvalidURL, HTTPError, PyEzvizError) as error: diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index fc07db89509..4e81ef6a6a7 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -1,19 +1,29 @@ """Support for Ezviz sensors.""" +from __future__ import annotations + import logging from pyezviz.constants import SensorType -from homeassistant.helpers.entity import Entity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo, Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER +from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz sensors based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] sensors = [] for idx, camera in enumerate(coordinator.data): @@ -32,7 +42,15 @@ async def async_setup_entry(hass, entry, async_add_entities): class EzvizSensor(CoordinatorEntity, Entity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, name, sensor_type_name): + coordinator: EzvizDataUpdateCoordinator + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + idx: int, + name: str, + sensor_type_name: str, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self._idx = idx @@ -43,22 +61,22 @@ class EzvizSensor(CoordinatorEntity, Entity): self._serial = self.coordinator.data[self._idx]["serial"] @property - def name(self): + def name(self) -> str: """Return the name of the Ezviz sensor.""" - return self._sensor_name + return self._name @property - def state(self): + def state(self) -> int | str: """Return the state of the sensor.""" return self.coordinator.data[self._idx][self._name] @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of this sensor.""" return f"{self._serial}_{self._sensor_name}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" return { "identifiers": {(DOMAIN, self._serial)}, @@ -69,6 +87,6 @@ class EzvizSensor(CoordinatorEntity, Entity): } @property - def device_class(self): + def device_class(self) -> str: """Device class for the sensor.""" return self.sensor_type_name diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py index 00230a3ac2d..9949dc18b23 100644 --- a/homeassistant/components/ezviz/switch.py +++ b/homeassistant/components/ezviz/switch.py @@ -1,26 +1,34 @@ """Support for Ezviz Switch sensors.""" +from __future__ import annotations + import logging +from typing import Any from pyezviz.constants import DeviceSwitchType +from pyezviz.exceptions import HTTPError, PyEzvizError from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DATA_COORDINATOR, DOMAIN, MANUFACTURER +from .coordinator import EzvizDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Ezviz switch based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] switch_entities = [] - supported_switches = [] - - for switches in DeviceSwitchType: - supported_switches.append(switches.value) - - supported_switches = set(supported_switches) + supported_switches = {switches.value for switches in DeviceSwitchType} for idx, camera in enumerate(coordinator.data): if not camera.get("switches"): @@ -36,7 +44,11 @@ async def async_setup_entry(hass, entry, async_add_entities): class EzvizSwitch(CoordinatorEntity, SwitchEntity): """Representation of a Ezviz sensor.""" - def __init__(self, coordinator, idx, switch): + coordinator: EzvizDataUpdateCoordinator + + def __init__( + self, coordinator: EzvizDataUpdateCoordinator, idx: int, switch: str + ) -> None: """Initialize the switch.""" super().__init__(coordinator) self._idx = idx @@ -47,34 +59,48 @@ class EzvizSwitch(CoordinatorEntity, SwitchEntity): self._device_class = DEVICE_CLASS_SWITCH @property - def name(self): + def name(self) -> str: """Return the name of the Ezviz switch.""" - return f"{self._camera_name}.{DeviceSwitchType(self._name).name}" + return f"{DeviceSwitchType(self._name).name}" @property - def is_on(self): + def is_on(self) -> bool: """Return the state of the switch.""" return self.coordinator.data[self._idx]["switches"][self._name] @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique ID of this switch.""" return f"{self._serial}_{self._sensor_name}" - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to on", self._name) + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 1 + ) - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 1) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError("Failed to turn on switch {self._name}") from err - def turn_off(self, **kwargs): + if update_ok: + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: """Change a device switch on the camera.""" - _LOGGER.debug("Set EZVIZ Switch '%s' to off", self._name) + try: + update_ok = await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.switch_status, self._serial, self._name, 0 + ) - self.coordinator.ezviz_client.switch_status(self._serial, self._name, 0) + except (HTTPError, PyEzvizError) as err: + raise PyEzvizError(f"Failed to turn off switch {self._name}") from err + + if update_ok: + await self.coordinator.async_request_refresh() @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" return { "identifiers": {(DOMAIN, self._serial)}, @@ -85,6 +111,6 @@ class EzvizSwitch(CoordinatorEntity, SwitchEntity): } @property - def device_class(self): + def device_class(self) -> str: """Device class for the sensor.""" return self._device_class diff --git a/homeassistant/components/ezviz/translations/ar.json b/homeassistant/components/ezviz/translations/ar.json new file mode 100644 index 00000000000..4ebc8494679 --- /dev/null +++ b/homeassistant/components/ezviz/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "ezviz_cloud_account_missing": "\u062d\u0633\u0627\u0628 \u0633\u062d\u0627\u0628\u0629 Ezviz \u0645\u0641\u0642\u0648\u062f. \u064a\u0631\u062c\u0649 \u0625\u0639\u0627\u062f\u0629 \u062a\u0643\u0648\u064a\u0646 \u062d\u0633\u0627\u0628 \u0633\u062d\u0627\u0628\u0629 Ezviz" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/de.json b/homeassistant/components/ezviz/translations/de.json index ab860d44201..92faeff2b81 100644 --- a/homeassistant/components/ezviz/translations/de.json +++ b/homeassistant/components/ezviz/translations/de.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured_account": "Konto wurde bereits konfiguriert", - "ezviz_cloud_account_missing": "Ezviz-Cloud-Konto fehlt. Bitte konfigurieren Sie das Ezviz-Cloud-Konto neu", + "ezviz_cloud_account_missing": "Ezviz-Cloud-Konto fehlt. Bitte konfiguriere das Ezviz-Cloud-Konto neu", "unknown": "Unerwarteter Fehler" }, "error": { diff --git a/homeassistant/components/ezviz/translations/hu.json b/homeassistant/components/ezviz/translations/hu.json new file mode 100644 index 00000000000..3ece0a79dcf --- /dev/null +++ b/homeassistant/components/ezviz/translations/hu.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured_account": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "ezviz_cloud_account_missing": "Ezviz cloud fi\u00f3k hi\u00e1nyzik. K\u00e9rj\u00fck, konfigur\u00e1lja \u00fajra az Ezviz cloud fi\u00f3kot.", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_host": "\u00c9rv\u00e9nytelen gazdag\u00e9pn\u00e9v vagy IP-c\u00edm" + }, + "flow_title": "{serial}", + "step": { + "confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "\u00cdrja be az RTSP-hiteles\u00edt\u0151 adatokat az Ezviz {serial} kamer\u00e1hoz IP- {ip_address}", + "title": "Felfedezett Ezviz kamera" + }, + "user": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Csatlakozzon az Ezviz Cloud szolg\u00e1ltat\u00e1shoz" + }, + "user_custom_url": { + "data": { + "password": "Jelsz\u00f3", + "url": "URL", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Adja meg k\u00e9zzel a r\u00e9gi\u00f3 URL-j\u00e9t", + "title": "Csatlakozzon az Ezviz-hez egy\u00e9ni URL" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "ffmpeg_arguments": "A kamer\u00e1khoz az ffmpeg-nek \u00e1tadott argumentumok", + "timeout": "K\u00e9r\u00e9s id\u0151korl\u00e1tja (m\u00e1sodperc)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/faa_delays/translations/de.json b/homeassistant/components/faa_delays/translations/de.json index 9519c7d4470..b10619dc23d 100644 --- a/homeassistant/components/faa_delays/translations/de.json +++ b/homeassistant/components/faa_delays/translations/de.json @@ -13,7 +13,7 @@ "data": { "id": "Flughafen" }, - "description": "Geben Sie einen US-Flughafencode im IATA-Format ein", + "description": "Gib einen US-Flughafencode im IATA-Format ein", "title": "FAA Delays" } } diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 20a11fd89f1..1d0caa3231b 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with fans.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -22,7 +23,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass from homeassistant.util.percentage import ( @@ -224,9 +225,16 @@ def _fan_native(method): return method +@dataclass +class FanEntityDescription(ToggleEntityDescription): + """A class that describes fan entities.""" + + class FanEntity(ToggleEntity): """Base class for fan entities.""" + entity_description: FanEntityDescription + @_fan_native def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" @@ -246,7 +254,7 @@ class FanEntity(ToggleEntity): await self.async_turn_off() return - if speed in self.preset_modes: + if self.preset_modes and speed in self.preset_modes: if not hasattr(self.async_set_preset_mode, _FAN_NATIVE): await self.async_set_preset_mode(speed) return @@ -375,7 +383,7 @@ class FanEntity(ToggleEntity): _LOGGER.warning( "Calling fan.turn_on with the speed argument is deprecated, use percentage or preset_mode instead" ) - if speed in self.preset_modes: + if self.preset_modes and speed in self.preset_modes: preset_mode = speed percentage = None else: @@ -463,9 +471,13 @@ class FanEntity(ToggleEntity): @property def percentage(self) -> int | None: """Return the current speed as a percentage.""" - if not self._implemented_preset_mode and self.speed in self.preset_modes: + if ( + not self._implemented_preset_mode + and self.preset_modes + and self.speed in self.preset_modes + ): return None - if not self._implemented_percentage: + if self.speed is not None and not self._implemented_percentage: return self.speed_to_percentage(self.speed) return 0 @@ -488,7 +500,7 @@ class FanEntity(ToggleEntity): speeds = [] if self._implemented_percentage: speeds += [SPEED_OFF, *LEGACY_SPEED_LIST] - if self._implemented_preset_mode: + if self._implemented_preset_mode and self.preset_modes: speeds += self.preset_modes return speeds @@ -594,7 +606,7 @@ class FanEntity(ToggleEntity): @property def state_attributes(self) -> dict: """Return optional state attributes.""" - data = {} + data: dict[str, float | str | None] = {} supported_features = self.supported_features if supported_features & SUPPORT_DIRECTION: @@ -628,7 +640,7 @@ class FanEntity(ToggleEntity): Requires SUPPORT_SET_SPEED. """ speed = self.speed - if speed in self.preset_modes: + if self.preset_modes and speed in self.preset_modes: return speed return None diff --git a/homeassistant/components/fan/translations/he.json b/homeassistant/components/fan/translations/he.json index 63139b0fe34..db876480dfc 100644 --- a/homeassistant/components/fan/translations/he.json +++ b/homeassistant/components/fan/translations/he.json @@ -8,7 +8,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05de\u05d0\u05d5\u05d5\u05e8\u05e8" diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index e0a4782493e..f2424332a01 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -1,15 +1,20 @@ """Support for testing internet speed via Fast.com.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from fastdotcom import fast_com import voluptuous as vol from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType DOMAIN = "fastdotcom" DATA_UPDATED = f"{DOMAIN}_data_updated" @@ -35,7 +40,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Fast.com component.""" conf = config[DOMAIN] data = hass.data[DOMAIN] = SpeedtestData(hass) @@ -43,7 +48,7 @@ async def async_setup(hass, config): if not conf[CONF_MANUAL]: async_track_time_interval(hass, data.update, conf[CONF_SCAN_INTERVAL]) - def update(call=None): + def update(service_call: ServiceCall | None = None) -> None: """Service call to manually update the data.""" data.update() @@ -57,12 +62,12 @@ async def async_setup(hass, config): class SpeedtestData: """Get the latest data from fast.com.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize the data object.""" - self.data = None + self.data: dict[str, Any] | None = None self._hass = hass - def update(self, now=None): + def update(self) -> None: """Get the latest data from fast.com.""" _LOGGER.debug("Executing fast.com speedtest") diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index b4406a4de95..14f63a99e5d 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -1,16 +1,27 @@ """Support for Fast.com internet speed testing sensor.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.sensor import SensorEntity from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND -from homeassistant.core import callback +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 homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN ICON = "mdi:speedometer" -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Set up the Fast.com sensor.""" async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) @@ -18,38 +29,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" - def __init__(self, speedtest_data): + _attr_name = "Fast.com Download" + _attr_unit_of_measurement = DATA_RATE_MEGABITS_PER_SECOND + _attr_icon = ICON + _attr_should_poll = False + _attr_state = None + + def __init__(self, speedtest_data: dict[str, Any]) -> None: """Initialize the sensor.""" - self._name = "Fast.com Download" - self.speedtest_client = speedtest_data - self._state = None + self._speedtest_data = speedtest_data - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return DATA_RATE_MEGABITS_PER_SECOND - - @property - def icon(self): - """Return icon.""" - return ICON - - @property - def should_poll(self): - """Return the polling requirement for this sensor.""" - return False - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -62,15 +52,15 @@ class SpeedtestSensor(RestoreEntity, SensorEntity): state = await self.async_get_last_state() if not state: return - self._state = state.state + self._attr_state = state.state - def update(self): + def update(self) -> None: """Get the latest data and update the states.""" - data = self.speedtest_client.data + data = self._speedtest_data.data # type: ignore[attr-defined] if data is None: return - self._state = data["download"] + self._attr_state = data["download"] @callback - def _schedule_immediate_update(self): + def _schedule_immediate_update(self) -> None: self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index 55e34a547e3..52e034c6265 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -94,7 +94,7 @@ async def async_get_image( input_source: str, output_format: str = IMAGE_JPEG, extra_cmd: str | None = None, -): +) -> bytes | None: """Get an image from a frame of an RTSP stream.""" manager = hass.data[DATA_FFMPEG] ffmpeg = ImageFrame(manager.binary) diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 35c8ebf7df6..adfe15b7a3c 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -41,7 +41,7 @@ class FileNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Send a message to a file.""" - with open(self.filepath, "a") as file: + with open(self.filepath, "a", encoding="utf8") as file: if os.stat(self.filepath).st_size == 0: title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" file.write(title) diff --git a/homeassistant/components/fireservicerota/translations/de.json b/homeassistant/components/fireservicerota/translations/de.json index 737fbc5ff53..c8c18c4c372 100644 --- a/homeassistant/components/fireservicerota/translations/de.json +++ b/homeassistant/components/fireservicerota/translations/de.json @@ -1,14 +1,14 @@ { "config": { "abort": { - "already_configured": "Account wurde schon konfiguriert", - "reauth_successful": "Neuauthentifizierung erfolgreich" + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "create_entry": { - "default": "Authentifizierung erfolgreich" + "default": "Erfolgreich authentifiziert" }, "error": { - "invalid_auth": "Authentifizienung ung\u00fcltig" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "step": { "reauth": { @@ -21,7 +21,7 @@ "data": { "password": "Passwort", "url": "Webseite", - "username": "Nutzername" + "username": "Benutzername" } } } diff --git a/homeassistant/components/fireservicerota/translations/hu.json b/homeassistant/components/fireservicerota/translations/hu.json index 8e8432d5df4..54461091c93 100644 --- a/homeassistant/components/fireservicerota/translations/hu.json +++ b/homeassistant/components/fireservicerota/translations/hu.json @@ -14,7 +14,8 @@ "reauth": { "data": { "password": "Jelsz\u00f3" - } + }, + "description": "A hiteles\u00edt\u00e9si tokenek \u00e9rv\u00e9nytelenn\u00e9 v\u00e1ltak, a l\u00e9trehoz\u00e1shoz jelentkezzen be." }, "user": { "data": { diff --git a/homeassistant/components/flick_electric/translations/de.json b/homeassistant/components/flick_electric/translations/de.json index 13ae8555608..8409250c5fa 100644 --- a/homeassistant/components/flick_electric/translations/de.json +++ b/homeassistant/components/flick_electric/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/flick_electric/translations/he.json b/homeassistant/components/flick_electric/translations/he.json index 658cdb97588..b1e5464047b 100644 --- a/homeassistant/components/flick_electric/translations/he.json +++ b/homeassistant/components/flick_electric/translations/he.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "\u05d7\u05e9\u05d1\u05d5\u05df \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py new file mode 100644 index 00000000000..05bbd0d5449 --- /dev/null +++ b/homeassistant/components/flipr/__init__.py @@ -0,0 +1,90 @@ +"""The Flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import CONF_FLIPR_ID, DOMAIN, MANUFACTURER, NAME + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(minutes=60) + + +PLATFORMS = ["sensor"] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Flipr from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + coordinator = FliprDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """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 FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + self.flipr_id = entry.data[CONF_FLIPR_ID] + + _LOGGER.debug("Config entry values : %s, %s", username, self.flipr_id) + + # Establishes the connection. + self.client = FliprAPIRestClient(username, password) + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + return await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + def __init__(self, coordinator, flipr_id, info_type): + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{flipr_id}-{info_type}" + self._attr_device_info = { + "identifiers": {(DOMAIN, flipr_id)}, + "name": NAME, + "manufacturer": MANUFACTURER, + } + self.info_type = info_type + self.flipr_id = flipr_id diff --git a/homeassistant/components/flipr/config_flow.py b/homeassistant/components/flipr/config_flow.py new file mode 100644 index 00000000000..b503281fed4 --- /dev/null +++ b/homeassistant/components/flipr/config_flow.py @@ -0,0 +1,124 @@ +"""Config flow for Flipr integration.""" +from __future__ import annotations + +import logging + +from flipr_api import FliprAPIRestClient +from requests.exceptions import HTTPError, Timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import CONF_FLIPR_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Flipr.""" + + VERSION = 1 + + _username: str | None = None + _password: str | None = None + _flipr_id: str | None = None + _possible_flipr_ids: list[str] | None = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if user_input is None: + return self._show_setup_form() + + self._username = user_input[CONF_EMAIL] + self._password = user_input[CONF_PASSWORD] + + errors = {} + if not self._flipr_id: + try: + flipr_ids = await self._authenticate_and_search_flipr() + except HTTPError: + errors["base"] = "invalid_auth" + except (Timeout, ConnectionError): + errors["base"] = "cannot_connect" + except Exception as exception: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(exception) + + if not errors and len(flipr_ids) == 0: + # No flipr_id found. Tell the user with an error message. + errors["base"] = "no_flipr_id_found" + + if errors: + return self._show_setup_form(errors) + + if len(flipr_ids) == 1: + self._flipr_id = flipr_ids[0] + else: + # If multiple flipr found (rare case), we ask the user to choose one in a select box. + # The user will have to run config_flow as many times as many fliprs he has. + self._possible_flipr_ids = flipr_ids + return await self.async_step_flipr_id() + + # Check if already configured + await self.async_set_unique_id(self._flipr_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._flipr_id, + data={ + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + }, + ) + + def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_EMAIL): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def _authenticate_and_search_flipr(self) -> list[str]: + """Validate the username and password provided and searches for a flipr id.""" + client = await self.hass.async_add_executor_job( + FliprAPIRestClient, self._username, self._password + ) + + flipr_ids = await self.hass.async_add_executor_job(client.search_flipr_ids) + + return flipr_ids + + async def async_step_flipr_id(self, user_input=None): + """Handle the initial step.""" + if not user_input: + # Creation of a select with the proposal of flipr ids values found by API. + flipr_ids_for_form = {} + for flipr_id in self._possible_flipr_ids: + flipr_ids_for_form[flipr_id] = f"{flipr_id}" + + return self.async_show_form( + step_id="flipr_id", + data_schema=vol.Schema( + { + vol.Required(CONF_FLIPR_ID): vol.All( + vol.Coerce(str), vol.In(flipr_ids_for_form) + ) + } + ), + ) + + # Get chosen flipr_id. + self._flipr_id = user_input[CONF_FLIPR_ID] + + return await self.async_step_user( + { + CONF_EMAIL: self._username, + CONF_PASSWORD: self._password, + CONF_FLIPR_ID: self._flipr_id, + } + ) diff --git a/homeassistant/components/flipr/const.py b/homeassistant/components/flipr/const.py new file mode 100644 index 00000000000..d28353f4776 --- /dev/null +++ b/homeassistant/components/flipr/const.py @@ -0,0 +1,10 @@ +"""Constants for the Flipr integration.""" + +DOMAIN = "flipr" + +CONF_FLIPR_ID = "flipr_id" + +ATTRIBUTION = "Flipr Data" + +MANUFACTURER = "CTAC-TECH" +NAME = "Flipr" diff --git a/homeassistant/components/flipr/manifest.json b/homeassistant/components/flipr/manifest.json new file mode 100644 index 00000000000..330fea7de8b --- /dev/null +++ b/homeassistant/components/flipr/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "flipr", + "name": "Flipr", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flipr", + "requirements": [ + "flipr-api==1.4.1"], + "codeowners": [ + "@cnico" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py new file mode 100644 index 00000000000..427a668a72b --- /dev/null +++ b/homeassistant/components/flipr/sensor.py @@ -0,0 +1,90 @@ +"""Sensor platform for the Flipr's pool_sensor.""" +from datetime import datetime + +from homeassistant.const import ( + ATTR_ATTRIBUTION, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + TEMP_CELSIUS, +) +from homeassistant.helpers.entity import Entity + +from . import FliprEntity +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN + +SENSORS = { + "chlorine": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Chlorine", + "device_class": None, + }, + "ph": {"unit": None, "icon": "mdi:pool", "name": "pH", "device_class": None}, + "temperature": { + "unit": TEMP_CELSIUS, + "icon": None, + "name": "Water Temp", + "device_class": DEVICE_CLASS_TEMPERATURE, + }, + "date_time": { + "unit": None, + "icon": None, + "name": "Last Measured", + "device_class": DEVICE_CLASS_TIMESTAMP, + }, + "red_ox": { + "unit": "mV", + "icon": "mdi:pool", + "name": "Red OX", + "device_class": None, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + flipr_id = config_entry.data[CONF_FLIPR_ID] + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + sensors_list = [] + for sensor in SENSORS: + sensors_list.append(FliprSensor(coordinator, flipr_id, sensor)) + + async_add_entities(sensors_list, True) + + +class FliprSensor(FliprEntity, Entity): + """Sensor representing FliprSensor data.""" + + @property + def name(self): + """Return the name of the particular component.""" + return f"Flipr {self.flipr_id} {SENSORS[self.info_type]['name']}" + + @property + def state(self): + """State of the sensor.""" + state = self.coordinator.data[self.info_type] + if isinstance(state, datetime): + return state.isoformat() + return state + + @property + def device_class(self): + """Return the device class.""" + return SENSORS[self.info_type]["device_class"] + + @property + def icon(self): + """Return the icon.""" + return SENSORS[self.info_type]["icon"] + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return SENSORS[self.info_type]["unit"] + + @property + def device_state_attributes(self): + """Return device attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json new file mode 100644 index 00000000000..55feaa691f7 --- /dev/null +++ b/homeassistant/components/flipr/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to Flipr", + "description": "Connect using your Flipr account.", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, + "flipr_id": { + "title": "Choose your Flipr", + "description": "Choose your Flipr ID in the list", + "data": { + "flipr_id": "Flipr ID" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/flipr/translations/ca.json b/homeassistant/components/flipr/translations/ca.json new file mode 100644 index 00000000000..fcb43623030 --- /dev/null +++ b/homeassistant/components/flipr/translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "no_flipr_id_found": "De moment, no hi ha cap identificador de Flipr associat al teu compte. Primer hauries de verificar que funciona amb l'aplicaci\u00f3 m\u00f2bil de Flipr.", + "unknown": "Error inesperat" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Tria l'ID Flipr de la llista", + "title": "Tria el teu Flipr" + }, + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "description": "Connecta't amb el teu compte de Flipr.", + "title": "Connexi\u00f3 amb Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/cs.json b/homeassistant/components/flipr/translations/cs.json new file mode 100644 index 00000000000..29c2ebc1713 --- /dev/null +++ b/homeassistant/components/flipr/translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Heslo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/de.json b/homeassistant/components/flipr/translations/de.json new file mode 100644 index 00000000000..4dbbfbc9ef9 --- /dev/null +++ b/homeassistant/components/flipr/translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "no_flipr_id_found": "Deinem Konto ist im Moment keine Flipr-ID zugeordnet. Du solltest zuerst \u00fcberpr\u00fcfen, ob es mit der mobilen App von Flipr funktioniert.", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr-ID" + }, + "description": "W\u00e4hle deine Flipr-ID in der Liste", + "title": "W\u00e4hle deinen Flipr" + }, + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + }, + "description": "Verbinde dich mit deinem Flipr-Konto.", + "title": "Mit Flipr verbinden" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/en.json b/homeassistant/components/flipr/translations/en.json new file mode 100644 index 00000000000..667824d407b --- /dev/null +++ b/homeassistant/components/flipr/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "no_flipr_id_found": "No flipr id associated to your account for now. You should verify it is working with the Flipr's mobile app first.", + "unknown": "Unexpected error" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choose your Flipr ID in the list", + "title": "Choose your Flipr" + }, + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "description": "Connect using your Flipr account.", + "title": "Connect to Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/et.json b/homeassistant/components/flipr/translations/et.json new file mode 100644 index 00000000000..46be2f4378f --- /dev/null +++ b/homeassistant/components/flipr/translations/et.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Vigane autentimine", + "no_flipr_id_found": "Kontoga pole praegu \u00fchtegi flipr-it seostatud. K\u00f5igepealt pead kontrollima, kas see t\u00f6\u00f6tab Flipri mobiilirakendusega.", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipri ID" + }, + "description": "Vali loendist oma Flipri ID", + "title": "Vali oma Flipr" + }, + "user": { + "data": { + "email": "E-post", + "password": "Salas\u00f5na" + }, + "description": "\u00dchenda oma Flipr konto abil.", + "title": "Flipriga \u00fchenduse loomine" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/fr.json b/homeassistant/components/flipr/translations/fr.json new file mode 100644 index 00000000000..ec9260aa8a7 --- /dev/null +++ b/homeassistant/components/flipr/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide", + "no_flipr_id_found": "Aucun identifiant Flipr n'est associ\u00e9 \u00e0 votre compte pour le moment. Vous devez d'abord v\u00e9rifier qu'il fonctionne avec l'application mobile de Flipr.", + "unknown": "Erreur inattendue" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Choisissez votre ID Flipr dans la liste", + "title": "Choisir votre Flipr" + }, + "user": { + "data": { + "email": "Email", + "password": "Mot de passe" + }, + "description": "Connectez-vous \u00e0 votre compte Flipr.", + "title": "Connexion a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/he.json b/homeassistant/components/flipr/translations/he.json similarity index 61% rename from homeassistant/components/garmin_connect/translations/he.json rename to homeassistant/components/flipr/translations/he.json index e7bab78fd58..ecb8a74bc6f 100644 --- a/homeassistant/components/garmin_connect/translations/he.json +++ b/homeassistant/components/flipr/translations/he.json @@ -1,19 +1,18 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "too_many_requests": "\u05d1\u05e7\u05e9\u05d5\u05ea \u05e8\u05d1\u05d5\u05ea \u05de\u05d3\u05d9, \u05e0\u05d0 \u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05e0\u05d9\u05ea \u05de\u05d0\u05d5\u05d7\u05e8 \u05d9\u05d5\u05ea\u05e8.", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { "data": { - "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + "email": "\u05d3\u05d5\u05d0\"\u05dc", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4" } } } diff --git a/homeassistant/components/flipr/translations/it.json b/homeassistant/components/flipr/translations/it.json new file mode 100644 index 00000000000..399d4c6b9d3 --- /dev/null +++ b/homeassistant/components/flipr/translations/it.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "no_flipr_id_found": "Nessun ID flipr associato al tuo account per ora. Dovresti prima verificare che funzioni con l'app mobile di Flipr.", + "unknown": "Errore imprevisto" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "ID Flipr" + }, + "description": "Scegli il tuo ID Flipr nell'elenco", + "title": "Scegli il tuo Flipr" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Password" + }, + "description": "Connettiti usando il tuo account Flipr.", + "title": "Connettiti a Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/nl.json b/homeassistant/components/flipr/translations/nl.json new file mode 100644 index 00000000000..d66028ee244 --- /dev/null +++ b/homeassistant/components/flipr/translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "no_flipr_id_found": "Er is nog geen flipr id aan uw account gekoppeld. U moet eerst controleren of het werkt met de mobiele app van Flipr.", + "unknown": "Onverwachte fout" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "Kies uw Flipr-ID in de lijst", + "title": "Kies uw Flipr" + }, + "user": { + "data": { + "email": "E-mail", + "password": "Wachtwoord" + }, + "description": "Maak verbinding met uw Flipr-account.", + "title": "Maak verbinding met Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/pl.json b/homeassistant/components/flipr/translations/pl.json new file mode 100644 index 00000000000..436061f7f61 --- /dev/null +++ b/homeassistant/components/flipr/translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "no_flipr_id_found": "Brak identyfikatora Flipr powi\u0105zanego z Twoim kontem. Sprawd\u017a najpierw, czy dzia\u0142a z aplikacj\u0105 mobiln\u0105 Flipr.", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Identyfikator Flipr" + }, + "description": "Wybierz sw\u00f3j identyfikator Flipr z listy", + "title": "Wyb\u00f3r identyfikatora Flipr" + }, + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + }, + "description": "Po\u0142\u0105cz, u\u017cywaj\u0105c swojego konta Flipr.", + "title": "Po\u0142\u0105czenie z Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/ru.json b/homeassistant/components/flipr/translations/ru.json new file mode 100644 index 00000000000..d7625b5bb41 --- /dev/null +++ b/homeassistant/components/flipr/translations/ru.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "no_flipr_id_found": "\u041d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0441 \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e \u043d\u0435 \u0441\u0432\u044f\u0437\u0430\u043d\u044b Flipr ID. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0432\u0441\u0451 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0432 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u043c \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 Flipr.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 Flipr ID \u0438\u0437 \u0441\u043f\u0438\u0441\u043a\u0430", + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0412\u0430\u0448 Flipr" + }, + "user": { + "data": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0435\u0441\u044c, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044f \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Flipr.", + "title": "Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flipr/translations/zh-Hant.json b/homeassistant/components/flipr/translations/zh-Hant.json new file mode 100644 index 00000000000..546db0beccf --- /dev/null +++ b/homeassistant/components/flipr/translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "no_flipr_id_found": "\u76ee\u524d\u5e33\u865f\u4e2d\u6c92\u6709\u4efb\u4f55\u95dc\u806f\u7684 Flipr ID\uff0c\u8acb\u5148\u900f\u904e Flipr \u624b\u6a5f App \u9032\u884c\u9a57\u8b49\u3002", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "flipr_id": { + "data": { + "flipr_id": "Flipr ID" + }, + "description": "\u7531\u5217\u8868\u4e2d\u9078\u64c7 Flipr ID", + "title": "\u9078\u64c7 Flipr ID" + }, + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + }, + "description": "\u4f7f\u7528 Flipr \u5e33\u865f\u9032\u884c\u9023\u7dda\u3002", + "title": "\u9023\u7dda\u81f3 Flipr" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 4fc66d0ee70..1b441aa6ba5 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -47,7 +47,7 @@ def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): flume_devices = FlumeDeviceList(flume_auth, http_session=http_session) except RequestException as ex: raise ConfigEntryNotReady from ex - except Exception as ex: # pylint: disable=broad-except + except Exception as ex: raise ConfigEntryAuthFailed from ex return flume_auth, flume_devices, http_session diff --git a/homeassistant/components/flume/translations/de.json b/homeassistant/components/flume/translations/de.json index 2d1a67d9a74..574763f24cc 100644 --- a/homeassistant/components/flume/translations/de.json +++ b/homeassistant/components/flume/translations/de.json @@ -15,7 +15,7 @@ "password": "Passwort" }, "description": "Das Passwort f\u00fcr {username} ist nicht mehr g\u00fcltig.", - "title": "Authentifizieren Sie Ihr Flume-Konto erneut" + "title": "Authentifiziere dein Flume-Konto erneut" }, "user": { "data": { @@ -24,8 +24,8 @@ "password": "Passwort", "username": "Benutzername" }, - "description": "Um auf die Flume Personal API zugreifen zu k\u00f6nnen, m\u00fcssen Sie unter https://portal.flumetech.com/settings#token eine 'Client ID' und 'Client Secret' anfordern", - "title": "Stellen Sie eine Verbindung zu Ihrem Flume-Konto her" + "description": "Um auf die Flume Personal API zugreifen zu k\u00f6nnen, musst du unter https://portal.flumetech.com/settings#token eine 'Client ID' und 'Client Secret' anfordern", + "title": "Stelle eine Verbindung zu deinem Flume-Konto her" } } } diff --git a/homeassistant/components/flume/translations/fr.json b/homeassistant/components/flume/translations/fr.json index a111d66b937..5fe7fcf2ca4 100644 --- a/homeassistant/components/flume/translations/fr.json +++ b/homeassistant/components/flume/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." + "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", diff --git a/homeassistant/components/flume/translations/hu.json b/homeassistant/components/flume/translations/hu.json index cc0c820facf..e607ac4255e 100644 --- a/homeassistant/components/flume/translations/hu.json +++ b/homeassistant/components/flume/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,6 +10,13 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "A(z) {username} jelszava m\u00e1r nem \u00e9rv\u00e9nyes.", + "title": "Hiteles\u00edtse \u00fajra Flume-fi\u00f3kj\u00e1t" + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/flume/translations/id.json b/homeassistant/components/flume/translations/id.json index 333afb167e6..f72e27ece8d 100644 --- a/homeassistant/components/flume/translations/id.json +++ b/homeassistant/components/flume/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +10,11 @@ "unknown": "Kesalahan yang tidak diharapkan" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + } + }, "user": { "data": { "client_id": "ID Klien", diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py index 6eb4d54fe4f..22de54180a6 100644 --- a/homeassistant/components/flunearyou/__init__.py +++ b/homeassistant/components/flunearyou/__init__.py @@ -1,12 +1,17 @@ """The flunearyou component.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial +from typing import Any from pyflunearyou import Client from pyflunearyou.errors import FluNearYouError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -25,35 +30,35 @@ CONFIG_SCHEMA = cv.deprecated(DOMAIN) PLATFORMS = ["sensor"] -async def async_setup(hass, config): - """Set up the Flu Near You component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}} - return True - - -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Flu Near You as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}}) hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} websession = aiohttp_client.async_get_clientsession(hass) - client = Client(websession) + client = Client(session=websession) latitude = entry.data.get(CONF_LATITUDE, hass.config.latitude) longitude = entry.data.get(CONF_LONGITUDE, hass.config.longitude) - async def async_update(api_category): + async def async_update(api_category: str) -> dict[str, Any]: """Get updated date from the API based on category.""" try: if api_category == CATEGORY_CDC_REPORT: - return await client.cdc_reports.status_by_coordinates( + data = await client.cdc_reports.status_by_coordinates( + latitude, longitude + ) + else: + data = await client.user_reports.status_by_coordinates( latitude, longitude ) - return await client.user_reports.status_by_coordinates(latitude, longitude) except FluNearYouError as err: raise UpdateFailed(err) from err + return data + data_init_tasks = [] - for api_category in [CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT]: + for api_category in (CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT): coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ api_category ] = DataUpdateCoordinator( @@ -72,7 +77,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload an Flu Near You config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/flunearyou/config_flow.py b/homeassistant/components/flunearyou/config_flow.py index a63b6484a61..0005e0c257a 100644 --- a/homeassistant/components/flunearyou/config_flow.py +++ b/homeassistant/components/flunearyou/config_flow.py @@ -1,10 +1,15 @@ """Define a config flow manager for flunearyou.""" +from __future__ import annotations + +from typing import Any + from pyflunearyou import Client from pyflunearyou.errors import FluNearYouError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN, LOGGER @@ -16,7 +21,7 @@ class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 @property - def data_schema(self): + def data_schema(self) -> vol.Schema: """Return the data schema for integration.""" return vol.Schema( { @@ -29,7 +34,9 @@ class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form(step_id="user", data_schema=self.data_schema) @@ -40,7 +47,7 @@ class FluNearYouFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) - client = Client(websession) + client = Client(session=websession) try: await client.cdc_reports.status_by_coordinates( diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json index 71f0b49771e..5fd3eb6638f 100644 --- a/homeassistant/components/flunearyou/manifest.json +++ b/homeassistant/components/flunearyou/manifest.json @@ -3,7 +3,7 @@ "name": "Flu Near You", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/flunearyou", - "requirements": ["pyflunearyou==1.0.7"], + "requirements": ["pyflunearyou==2.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py index 066126c390e..88fb0147296 100644 --- a/homeassistant/components/flunearyou/sensor.py +++ b/homeassistant/components/flunearyou/sensor.py @@ -1,13 +1,20 @@ """Support for user- and CDC-based flu info sensors from Flu Near You.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_STATE, CONF_LATITUDE, CONF_LONGITUDE, ) -from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import CATEGORY_CDC_REPORT, CATEGORY_USER_REPORT, DATA_COORDINATOR, DOMAIN @@ -53,17 +60,19 @@ EXTENDED_SENSOR_TYPE_MAPPING = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Flu Near You sensors based on a config entry.""" - coordinators = hass.data[DOMAIN][DATA_COORDINATOR][config_entry.entry_id] + coordinators = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] - sensors = [] + sensors: list[CdcSensor | UserSensor] = [] for (sensor_type, name, icon, unit) in CDC_SENSORS: sensors.append( CdcSensor( coordinators[CATEGORY_CDC_REPORT], - config_entry, + entry, sensor_type, name, icon, @@ -75,7 +84,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors.append( UserSensor( coordinators[CATEGORY_USER_REPORT], - config_entry, + entry, sensor_type, name, icon, @@ -89,49 +98,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class FluNearYouSensor(CoordinatorEntity, SensorEntity): """Define a base Flu Near You sensor.""" - def __init__(self, coordinator, config_entry, sensor_type, name, icon, unit): + def __init__( + self, + coordinator: DataUpdateCoordinator, + entry: ConfigEntry, + sensor_type: str, + name: str, + icon: str, + unit: str | None, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._config_entry = config_entry - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._state = None - self._unit = unit - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return ( - f"{self._config_entry.data[CONF_LATITUDE]}," - f"{self._config_entry.data[CONF_LONGITUDE]}_{self._sensor_type}" + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_icon = icon + self._attr_name = name + self._attr_unique_id = ( + f"{entry.data[CONF_LATITUDE]}," + f"{entry.data[CONF_LONGITUDE]}_{sensor_type}" ) - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit + self._attr_unit_of_measurement = unit + self._entry = entry + self._sensor_type = sensor_type @callback def _handle_coordinator_update(self) -> None: @@ -139,13 +126,13 @@ class FluNearYouSensor(CoordinatorEntity, SensorEntity): self.update_from_latest_data() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() self.update_from_latest_data() @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" raise NotImplementedError @@ -154,24 +141,24 @@ class CdcSensor(FluNearYouSensor): """Define a sensor for CDC reports.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_REPORTED_DATE: self.coordinator.data["week_date"], ATTR_STATE: self.coordinator.data["name"], } ) - self._state = self.coordinator.data[self._sensor_type] + self._attr_state = self.coordinator.data[self._sensor_type] class UserSensor(FluNearYouSensor): """Define a sensor for user reports.""" @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor.""" - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_CITY: self.coordinator.data["local"]["city"].split("(")[0], ATTR_REPORTED_LATITUDE: self.coordinator.data["local"]["latitude"], @@ -186,15 +173,15 @@ class UserSensor(FluNearYouSensor): elif self._sensor_type in EXTENDED_SENSOR_TYPE_MAPPING: states_key = EXTENDED_SENSOR_TYPE_MAPPING[self._sensor_type] - self._attrs[ATTR_STATE_REPORTS_THIS_WEEK] = self.coordinator.data["state"][ - "data" - ][states_key] - self._attrs[ATTR_STATE_REPORTS_LAST_WEEK] = self.coordinator.data["state"][ - "last_week_data" - ][states_key] + self._attr_extra_state_attributes[ + ATTR_STATE_REPORTS_THIS_WEEK + ] = self.coordinator.data["state"]["data"][states_key] + self._attr_extra_state_attributes[ + ATTR_STATE_REPORTS_LAST_WEEK + ] = self.coordinator.data["state"]["last_week_data"][states_key] if self._sensor_type == SENSOR_TYPE_USER_TOTAL: - self._state = sum( + self._attr_state = sum( v for k, v in self.coordinator.data["local"].items() if k @@ -207,4 +194,4 @@ class UserSensor(FluNearYouSensor): ) ) else: - self._state = self.coordinator.data["local"][self._sensor_type] + self._attr_state = self.coordinator.data["local"][self._sensor_type] diff --git a/homeassistant/components/flunearyou/translations/de.json b/homeassistant/components/flunearyou/translations/de.json index 585923fb2bf..61dd2cd4ce7 100644 --- a/homeassistant/components/flunearyou/translations/de.json +++ b/homeassistant/components/flunearyou/translations/de.json @@ -12,8 +12,8 @@ "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, - "description": "\u00dcberwachen Sie benutzerbasierte und CDC-Berichte f\u00fcr ein Koordinatenpaar.", - "title": "Konfigurieren Sie die Grippe in Ihrer N\u00e4he" + "description": "\u00dcberwache benutzerbasierte und CDC-Berichte f\u00fcr ein Koordinatenpaar.", + "title": "Konfiguriere Grippe in deiner N\u00e4he" } } } diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index 996ac1b1049..d635f231818 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_TOKEN, CONF_USERNAME, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, TIME_SECONDS, @@ -36,17 +37,23 @@ ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" ATTR_FOOBOT_INDEX = "index" SENSOR_TYPES = { - "time": [ATTR_TIME, TIME_SECONDS], - "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], - "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], - "hum": [ATTR_HUMIDITY, PERCENTAGE, "mdi:water-percent"], - "co2": [ATTR_CARBON_DIOXIDE, CONCENTRATION_PARTS_PER_MILLION, "mdi:molecule-co2"], + "time": [ATTR_TIME, TIME_SECONDS, None, None], + "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud", None], + "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + "hum": [ATTR_HUMIDITY, PERCENTAGE, "mdi:water-percent", None], + "co2": [ + ATTR_CARBON_DIOXIDE, + CONCENTRATION_PARTS_PER_MILLION, + "mdi:molecule-co2", + None, + ], "voc": [ ATTR_VOLATILE_ORGANIC_COMPOUNDS, CONCENTRATION_PARTS_PER_BILLION, "mdi:cloud", + None, ], - "allpollu": [ATTR_FOOBOT_INDEX, PERCENTAGE, "mdi:percent"], + "allpollu": [ATTR_FOOBOT_INDEX, PERCENTAGE, "mdi:percent", None], } SCAN_INTERVAL = timedelta(minutes=10) @@ -108,6 +115,11 @@ class FoobotSensor(SensorEntity): """Return the name of the sensor.""" return self._name + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return SENSOR_TYPES[self.type][3] + @property def icon(self): """Icon to use in the frontend.""" diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index b00e5f1c4ce..4d996736ecf 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -5,10 +5,12 @@ from datetime import timedelta import logging from forecast_solar import ForecastSolar +import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -55,7 +57,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {} + websocket_api.async_register_command(hass, ws_list_forecasts) hass.data[DOMAIN][entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -77,3 +81,22 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" await hass.config_entries.async_reload(entry.entry_id) + + +@websocket_api.websocket_command({vol.Required("type"): "forecast_solar/forecasts"}) +@callback +def ws_list_forecasts( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Return a list of available forecasts.""" + forecasts = {} + + for config_entry_id, coordinator in hass.data[DOMAIN].items(): + forecasts[config_entry_id] = { + "wh_hours": { + timestamp.isoformat(): val + for timestamp, val in coordinator.data.wh_hours.items() + } + } + + connection.send_result(msg["id"], forecasts) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index 12aa1ee5362..7ae6fe01d42 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -1,6 +1,7 @@ """Constants for the Forecast.Solar integration.""" from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT @@ -12,7 +13,7 @@ from homeassistant.const import ( POWER_WATT, ) -from .models import ForecastSolarSensor +from .models import ForecastSolarSensorEntityDescription DOMAIN = "forecast_solar" @@ -23,67 +24,81 @@ CONF_DAMPING = "damping" ATTR_ENTRY_TYPE: Final = "entry_type" ENTRY_TYPE_SERVICE: Final = "service" -SENSORS: list[ForecastSolarSensor] = [ - ForecastSolarSensor( +SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = ( + ForecastSolarSensorEntityDescription( key="energy_production_today", name="Estimated Energy Production - Today", + state=lambda estimate: estimate.energy_production_today / 1000, device_class=DEVICE_CLASS_ENERGY, unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="energy_production_tomorrow", name="Estimated Energy Production - Tomorrow", + state=lambda estimate: estimate.energy_production_tomorrow / 1000, device_class=DEVICE_CLASS_ENERGY, unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_highest_peak_time_today", name="Highest Power Peak Time - Today", device_class=DEVICE_CLASS_TIMESTAMP, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_highest_peak_time_tomorrow", name="Highest Power Peak Time - Tomorrow", device_class=DEVICE_CLASS_TIMESTAMP, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_production_now", name="Estimated Power Production - Now", device_class=DEVICE_CLASS_POWER, + state=lambda estimate: estimate.power_production_now, state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_production_next_hour", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=1) + ), name="Estimated Power Production - Next Hour", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, unit_of_measurement=POWER_WATT, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_production_next_12hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=12) + ), name="Estimated Power Production - Next 12 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, unit_of_measurement=POWER_WATT, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="power_production_next_24hours", + state=lambda estimate: estimate.power_production_at_time( + estimate.now() + timedelta(hours=24) + ), name="Estimated Power Production - Next 24 Hours", device_class=DEVICE_CLASS_POWER, entity_registry_enabled_default=False, unit_of_measurement=POWER_WATT, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="energy_current_hour", name="Estimated Energy Production - This Hour", + state=lambda estimate: estimate.energy_current_hour / 1000, device_class=DEVICE_CLASS_ENERGY, unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), - ForecastSolarSensor( + ForecastSolarSensorEntityDescription( key="energy_next_hour", + state=lambda estimate: estimate.sum_energy_production(1) / 1000, name="Estimated Energy Production - Next Hour", device_class=DEVICE_CLASS_ENERGY, unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), -] +) diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json index c17e8bd51f8..2b57eed84ac 100644 --- a/homeassistant/components/forecast_solar/manifest.json +++ b/homeassistant/components/forecast_solar/manifest.json @@ -3,7 +3,7 @@ "name": "Forecast.Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/forecast_solar", - "requirements": ["forecast_solar==1.3.1"], + "requirements": ["forecast_solar==2.0.0"], "codeowners": ["@klaasnicolaas", "@frenck"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/forecast_solar/models.py b/homeassistant/components/forecast_solar/models.py index d01f17fc975..6bcc97d49f2 100644 --- a/homeassistant/components/forecast_solar/models.py +++ b/homeassistant/components/forecast_solar/models.py @@ -2,16 +2,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any, Callable + +from forecast_solar.models import Estimate + +from homeassistant.components.sensor import SensorEntityDescription @dataclass -class ForecastSolarSensor: - """Represents an Forecast.Solar Sensor.""" +class ForecastSolarSensorEntityDescription(SensorEntityDescription): + """Describes a Forecast.Solar Sensor.""" - key: str - name: str - - device_class: str | None = None - entity_registry_enabled_default: bool = True - state_class: str | None = None - unit_of_measurement: str | None = None + state: Callable[[Estimate], Any] | None = None diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index b32f1f341be..5d3f440f4b6 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -15,7 +15,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ATTR_ENTRY_TYPE, DOMAIN, ENTRY_TYPE_SERVICE, SENSORS -from .models import ForecastSolarSensor +from .models import ForecastSolarSensorEntityDescription async def async_setup_entry( @@ -26,35 +26,31 @@ async def async_setup_entry( async_add_entities( ForecastSolarSensorEntity( - entry_id=entry.entry_id, coordinator=coordinator, sensor=sensor + entry_id=entry.entry_id, + coordinator=coordinator, + entity_description=entity_description, ) - for sensor in SENSORS + for entity_description in SENSORS ) class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): """Defines a Forcast.Solar sensor.""" + entity_description: ForecastSolarSensorEntityDescription + def __init__( self, *, entry_id: str, coordinator: DataUpdateCoordinator, - sensor: ForecastSolarSensor, + entity_description: ForecastSolarSensorEntityDescription, ) -> None: """Initialize Forcast.Solar sensor.""" super().__init__(coordinator=coordinator) - self._sensor = sensor - - self.entity_id = f"{SENSOR_DOMAIN}.{sensor.key}" - self._attr_device_class = sensor.device_class - self._attr_entity_registry_enabled_default = ( - sensor.entity_registry_enabled_default - ) - self._attr_name = sensor.name - self._attr_state_class = sensor.state_class - self._attr_unique_id = f"{entry_id}_{sensor.key}" - self._attr_unit_of_measurement = sensor.unit_of_measurement + self.entity_description = entity_description + self.entity_id = f"{SENSOR_DOMAIN}.{entity_description.key}" + self._attr_unique_id = f"{entry_id}_{entity_description.key}" self._attr_device_info = { ATTR_IDENTIFIERS: {(DOMAIN, entry_id)}, @@ -66,7 +62,13 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): @property def state(self) -> StateType: """Return the state of the sensor.""" - state: StateType | datetime = getattr(self.coordinator.data, self._sensor.key) + if self.entity_description.state is None: + state: StateType | datetime = getattr( + self.coordinator.data, self.entity_description.key + ) + else: + state = self.entity_description.state(self.coordinator.data) + if isinstance(state, datetime): return state.isoformat() return state diff --git a/homeassistant/components/forecast_solar/translations/ar.json b/homeassistant/components/forecast_solar/translations/ar.json new file mode 100644 index 00000000000..1c213149988 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ar.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "modules power": "\u0625\u062c\u0645\u0627\u0644\u064a \u0637\u0627\u0642\u0629 \u0630\u0631\u0648\u0629 \u0648\u0627\u0637 \u0644\u0648\u062d\u062f\u0627\u062a \u0627\u0644\u0637\u0627\u0642\u0629 \u0627\u0644\u0634\u0645\u0633\u064a\u0629 \u0627\u0644\u062e\u0627\u0635\u0629 \u0628\u0643" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/ca.json b/homeassistant/components/forecast_solar/translations/ca.json new file mode 100644 index 00000000000..7bd31828080 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/ca.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 graus, 0 = nord, 90 = est, 180 = sud, 270 = oest)", + "declination": "Inclinaci\u00f3 (0 = horitzontal, 90 = vertical)", + "latitude": "Latitud", + "longitude": "Longitud", + "modules power": "Pot\u00e8ncia m\u00e0xima total dels panells solars", + "name": "Nom" + }, + "description": "Introdueix les dades dels teus panells solars. Consulta la documentaci\u00f3 si tens dubtes en algun camp." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Clau API de Forecast.Solar (opcional)", + "azimuth": "Azimut (360 graus, 0 = nord, 90 = est, 180 = sud, 270 = oest)", + "damping": "Factor d'amortiment: ajusta els resultats al mat\u00ed i al vespre", + "declination": "Inclinaci\u00f3 (0 = horitzontal, 90 = vertical)", + "modules power": "Pot\u00e8ncia m\u00e0xima total dels panells solars" + }, + "description": "Aquests valors permeten ajustar els resultats de Solar.Forecast. Consulta la documentaci\u00f3 si tens dubtes sobre algun camp." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/de.json b/homeassistant/components/forecast_solar/translations/de.json index 86b51a14845..43b60424cf1 100644 --- a/homeassistant/components/forecast_solar/translations/de.json +++ b/homeassistant/components/forecast_solar/translations/de.json @@ -7,7 +7,7 @@ "declination": "Deklination (0 = Horizontal, 90 = Vertikal)", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad", - "modules power": "Gesamt-Watt-Spitzenleistung Ihrer Solarmodule", + "modules power": "Gesamt-Watt-Spitzenleistung deiner Solarmodule", "name": "Name" }, "description": "Gib die Daten deiner Solarmodule ein. Wenn ein Feld unklar ist, schlage bitte in der Dokumentation nach." @@ -22,7 +22,7 @@ "azimuth": "Azimut (360 Grad, 0 = Norden, 90 = Osten, 180 = S\u00fcden, 270 = Westen)", "damping": "D\u00e4mpfungsfaktor: passt die Ergebnisse morgens und abends an", "declination": "Deklination (0 = Horizontal, 90 = Vertikal)", - "modules power": "Gesamt-Watt-Spitzenleistung Ihrer Solarmodule" + "modules power": "Gesamt-Watt-Spitzenleistung deiner Solarmodule" }, "description": "Mit diesen Werten kann das Solar.Forecast-Ergebnis angepasst werden. Wenn ein Feld unklar ist, lies bitte in der Dokumentation nach." } diff --git a/homeassistant/components/forecast_solar/translations/en.json b/homeassistant/components/forecast_solar/translations/en.json index 6de9cddc567..f9eef2b5c0a 100644 --- a/homeassistant/components/forecast_solar/translations/en.json +++ b/homeassistant/components/forecast_solar/translations/en.json @@ -24,7 +24,7 @@ "declination": "Declination (0 = Horizontal, 90 = Vertical)", "modules power": "Total Watt peak power of your solar modules" }, - "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation is a field is unclear." + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation if a field is unclear." } } } diff --git a/homeassistant/components/forecast_solar/translations/es.json b/homeassistant/components/forecast_solar/translations/es.json new file mode 100644 index 00000000000..2189cb91f77 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/es.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data": { + "azimuth": "Azimut (360 grados, 0 = Norte, 90 = Este, 180 = Sur, 270 = Oeste)", + "damping": "Factor de amortiguaci\u00f3n: ajusta los resultados por la ma\u00f1ana y por la noche", + "declination": "Declinaci\u00f3n (0 = Horizontal, 90 = Vertical)", + "modules power": "Potencia pico total en vatios de tus m\u00f3dulos solares" + }, + "description": "Estos valores permiten ajustar el resultado de Solar.Forecast. Consulte la documentaci\u00f3n si un campo no est\u00e1 claro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/et.json b/homeassistant/components/forecast_solar/translations/et.json index 6dddf4a7496..7aa87f4cf58 100644 --- a/homeassistant/components/forecast_solar/translations/et.json +++ b/homeassistant/components/forecast_solar/translations/et.json @@ -24,7 +24,7 @@ "declination": "Deklinatsioon (0 = horisontaalne, 90 = vertikaalne)", "modules power": "P\u00e4ikesemoodulite koguv\u00f5imsus vattides" }, - "description": "Need v\u00e4\u00e4rtused v\u00f5imaldavad muuta Solar.Forecast tulemust. Vaata dokumentatsiooni kui on ebaselge." + "description": "Need v\u00e4\u00e4rtused v\u00f5imaldavad muuta Solar.Forecast tulemust. Vaata dokumentatsiooni kui asi on ebaselge." } } } diff --git a/homeassistant/components/forecast_solar/translations/fr.json b/homeassistant/components/forecast_solar/translations/fr.json new file mode 100644 index 00000000000..efd9f7be3a6 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "latitude": "Latitude", + "longitude": "Longitude", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires", + "name": "Nom" + }, + "description": "Remplissez les donn\u00e9es de vos panneaux solaires. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Cl\u00e9 API Forecast.Solar (facultatif)", + "azimuth": "Azimut (360 degr\u00e9s, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ouest)", + "damping": "Facteur d'amortissement : ajuste les r\u00e9sultats matin et soir", + "declination": "D\u00e9clinaison (0 = horizontale, 90 = verticale)", + "modules power": "Puissance de cr\u00eate totale en watts de vos modules solaires" + }, + "description": "Ces valeurs permettent de peaufiner le r\u00e9sultat Solar.Forecast. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation si un champ n'est pas clair." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/he.json b/homeassistant/components/forecast_solar/translations/he.json new file mode 100644 index 00000000000..99eeb837dc3 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", + "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/hu.json b/homeassistant/components/forecast_solar/translations/hu.json index b863d10e907..0bd814f16be 100644 --- a/homeassistant/components/forecast_solar/translations/hu.json +++ b/homeassistant/components/forecast_solar/translations/hu.json @@ -3,8 +3,28 @@ "step": { "user": { "data": { + "azimuth": "Azimut (360 fok, 0 = \u00e9szak, 90 = keleti, 180 = d\u00e9li, 270 = nyugati)", + "declination": "Deklin\u00e1ci\u00f3 (0 = v\u00edzszintes, 90 = f\u00fcgg\u0151leges)", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "modules power": "A napelemmodulok teljes cs\u00facsteljes\u00edtm\u00e9nye (Watt)", "name": "N\u00e9v" - } + }, + "description": "T\u00f6ltse ki a napelemek adatait. K\u00e9rj\u00fck, olvassa el a dokument\u00e1ci\u00f3t, ha egy mez\u0151 nem egy\u00e9rtelm\u0171." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API kulcs (opcion\u00e1lis)", + "azimuth": "Azimut (360 fok, 0 = \u00e9szak, 90 = keleti, 180 = d\u00e9li, 270 = nyugati)", + "damping": "Csillap\u00edt\u00e1si t\u00e9nyez\u0151: be\u00e1ll\u00edtja az eredm\u00e9nyeket reggelre \u00e9s est\u00e9re", + "declination": "Deklin\u00e1ci\u00f3 (0 = v\u00edzszintes, 90 = f\u00fcgg\u0151leges)", + "modules power": "A napelemmodulok teljes cs\u00facsteljes\u00edtm\u00e9nye (Watt)" + }, + "description": "Ezek az \u00e9rt\u00e9kek lehet\u0151v\u00e9 teszik a Solar.Forecast eredm\u00e9ny m\u00f3dos\u00edt\u00e1s\u00e1t. K\u00e9rj\u00fck, olvassa el a dokument\u00e1ci\u00f3t, ha egy mez\u0151 nem egy\u00e9rtelm\u0171." } } } diff --git a/homeassistant/components/garmin_connect/translations/lv.json b/homeassistant/components/forecast_solar/translations/id.json similarity index 57% rename from homeassistant/components/garmin_connect/translations/lv.json rename to homeassistant/components/forecast_solar/translations/id.json index 2c205bdd324..b0a5ddcdc7e 100644 --- a/homeassistant/components/garmin_connect/translations/lv.json +++ b/homeassistant/components/forecast_solar/translations/id.json @@ -3,8 +3,7 @@ "step": { "user": { "data": { - "password": "Parole", - "username": "Lietot\u0101jv\u0101rds" + "latitude": "Lintang" } } } diff --git a/homeassistant/components/forecast_solar/translations/it.json b/homeassistant/components/forecast_solar/translations/it.json new file mode 100644 index 00000000000..1f7f7677888 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 gradi, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ovest)", + "declination": "Declinazione (0 = Orizzontale, 90 = Verticale)", + "latitude": "Latitudine", + "longitude": "Logitudine", + "modules power": "Potenza di picco totale in Watt dei tuoi moduli solari", + "name": "Nome" + }, + "description": "Compila i dati dei tuoi pannelli solari. Fare riferimento alla documentazione se un campo non \u00e8 chiaro." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Chiave API Forecast.Solar (opzionale)", + "azimuth": "Azimut (360 gradi, 0 = Nord, 90 = Est, 180 = Sud, 270 = Ovest)", + "damping": "Fattore di smorzamento: regola i risultati al mattino e alla sera", + "declination": "Declinazione (0 = Orizzontale, 90 = Verticale)", + "modules power": "Potenza di picco totale in Watt dei tuoi moduli solari" + }, + "description": "Questi valori consentono di modificare il risultato di Solar.Forecast. Fare riferimento alla documentazione se un campo non \u00e8 chiaro." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/nl.json b/homeassistant/components/forecast_solar/translations/nl.json new file mode 100644 index 00000000000..c66d272782d --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/nl.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azimut (360 graden, 0 = Noord, 90 = Oost, 180 = Zuid, 270 = West)", + "declination": "Declinatie (0 = Horizontaal, 90 = Verticaal)", + "latitude": "Breedtegraad", + "longitude": "Lengtegraad", + "modules power": "Totaal Watt piekvermogen van uw zonnepanelen", + "name": "Naam" + }, + "description": "Vul de gegevens van uw zonnepanelen in. Raadpleeg de documentatie als een veld niet duidelijk is." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Forecast.Solar API-sleutel (optioneel)", + "azimuth": "Azimut (360 graden, 0 = Noord, 90 = Oost, 180 = Zuid, 270 = West)", + "damping": "Dempingsfactor: past de resultaten 's ochtends en 's avonds aan", + "declination": "Declinatie (0 = Horizontaal, 90 = Verticaal)", + "modules power": "Totaal Watt piekvermogen van uw zonnepanelen" + }, + "description": "Met deze waarden kan het resultaat van Solar.Forecast worden aangepast. Raadpleeg de documentatie als een veld onduidelijk is." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/pl.json b/homeassistant/components/forecast_solar/translations/pl.json new file mode 100644 index 00000000000..8c1f96ea709 --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "data": { + "azimuth": "Azymut (360 stopni, 0 = P\u00f3\u0142noc, 90 = Wsch\u00f3d, 180 = Po\u0142udnie, 270 = Zach\u00f3d)", + "declination": "Deklinacja (0 = Poziomo, 90 = Pionowo)", + "latitude": "Szeroko\u015b\u0107 geograficzna", + "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "modules power": "Ca\u0142kowita moc szczytowa modu\u0142\u00f3w fotowoltaicznych w watach", + "name": "Nazwa" + }, + "description": "Wpisz dane swoich paneli s\u0142onecznych. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." + } + } + }, + "options": { + "step": { + "init": { + "data": { + "api_key": "Klucz API dla Forecast.Solar (opcjonalnie)", + "azimuth": "Azymut (360 stopni, 0 = P\u00f3\u0142noc, 90 = Wsch\u00f3d, 180 = Po\u0142udnie, 270 = Zach\u00f3d)", + "damping": "Wsp\u00f3\u0142czynnik t\u0142umienia: dostosowuje wyniki rano i wieczorem", + "declination": "Deklinacja (0 = Poziomo, 90 = Pionowo)", + "modules power": "Ca\u0142kowita moc szczytowa modu\u0142\u00f3w fotowoltaicznych w watach" + }, + "description": "Te warto\u015bci pozwalaj\u0105 dostosowa\u0107 wyniki dla Solar.Forecast. Prosz\u0119 zapozna\u0107 si\u0119 z dokumentacj\u0105, je\u015bli pole jest niejasne." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/forecast_solar/translations/ru.json b/homeassistant/components/forecast_solar/translations/ru.json index 1becfb2f5cb..bd1e4ae70c0 100644 --- a/homeassistant/components/forecast_solar/translations/ru.json +++ b/homeassistant/components/forecast_solar/translations/ru.json @@ -10,7 +10,7 @@ "modules power": "\u041e\u0431\u0449\u0430\u044f \u043f\u0438\u043a\u043e\u0432\u0430\u044f \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 (\u0432 \u0412\u0430\u0442\u0442\u0430\u0445)", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, - "description": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043e \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043f\u0430\u043d\u0435\u043b\u044f\u0445." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Forecast.Solar." } } }, @@ -24,7 +24,7 @@ "declination": "\u0421\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u0435 (0 = \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u043e\u0435, 90 = \u0432\u0435\u0440\u0442\u0438\u043a\u0430\u043b\u044c\u043d\u043e\u0435)", "modules power": "\u041e\u0431\u0449\u0430\u044f \u043f\u0438\u043a\u043e\u0432\u0430\u044f \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u044c \u0412\u0430\u0448\u0438\u0445 \u0441\u043e\u043b\u043d\u0435\u0447\u043d\u044b\u0445 \u043c\u043e\u0434\u0443\u043b\u0435\u0439 (\u0432 \u0412\u0430\u0442\u0442\u0430\u0445)" }, - "description": "\u042d\u0442\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u044e\u0442 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442 Forecast.Solar." + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 Forecast.Solar." } } } diff --git a/homeassistant/components/forked_daapd/translations/de.json b/homeassistant/components/forked_daapd/translations/de.json index 2047414b168..51fd312fd6d 100644 --- a/homeassistant/components/forked_daapd/translations/de.json +++ b/homeassistant/components/forked_daapd/translations/de.json @@ -5,8 +5,8 @@ "not_forked_daapd": "Das Ger\u00e4t ist kein Forked-Daapd-Server." }, "error": { - "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfen Sie Ihre forked-daapd-Netzwerkberechtigungen.", - "unknown_error": "Unbekannter Fehler", + "forbidden": "Verbindung kann nicht hergestellt werden. Bitte \u00fcberpr\u00fcfe deine forked-daapd-Netzwerkberechtigungen.", + "unknown_error": "Unerwarteter Fehler", "websocket_not_enabled": "Forked-Daapd-Server-Websocket nicht aktiviert.", "wrong_host_or_port": "Verbindung konnte nicht hergestellt werden. Bitte Host und Port pr\u00fcfen.", "wrong_password": "Ung\u00fcltiges Passwort", diff --git a/homeassistant/components/forked_daapd/translations/he.json b/homeassistant/components/forked_daapd/translations/he.json index 31c52a3faad..39bd36133d5 100644 --- a/homeassistant/components/forked_daapd/translations/he.json +++ b/homeassistant/components/forked_daapd/translations/he.json @@ -4,6 +4,7 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { + "unknown_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", "wrong_host_or_port": "\u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8. \u05e0\u05d0 \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05de\u05d0\u05e8\u05d7 \u05d5\u05d0\u05ea \u05d4\u05d9\u05e6\u05d9\u05d0\u05d4.", "wrong_password": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05d2\u05d5\u05d9\u05d4." }, diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json index 5c0622af9d1..14aa88b7952 100644 --- a/homeassistant/components/foscam/strings.json +++ b/homeassistant/components/foscam/strings.json @@ -1,5 +1,4 @@ { - "title": "Foscam", "config": { "step": { "user": { diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 8c4e611827e..e68f7208538 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -50,25 +50,23 @@ async def async_setup_entry( ) ) - for sensor_key in CONNECTION_SENSORS: - entities.append( - FreeboxSensor(router, sensor_key, CONNECTION_SENSORS[sensor_key]) - ) + for sensor_key, sensor in CONNECTION_SENSORS.items(): + entities.append(FreeboxSensor(router, sensor_key, sensor)) - for sensor_key in CALL_SENSORS: - entities.append(FreeboxCallSensor(router, sensor_key, CALL_SENSORS[sensor_key])) + for sensor_key, sensor in CALL_SENSORS.items(): + entities.append(FreeboxCallSensor(router, sensor_key, sensor)) _LOGGER.debug("%s - %s - %s disk(s)", router.name, router.mac, len(router.disks)) for disk in router.disks.values(): for partition in disk["partitions"]: - for sensor_key in DISK_PARTITION_SENSORS: + for sensor_key, sensor in DISK_PARTITION_SENSORS.items(): entities.append( FreeboxDiskSensor( router, disk, partition, sensor_key, - DISK_PARTITION_SENSORS[sensor_key], + sensor, ) ) diff --git a/homeassistant/components/freebox/translations/de.json b/homeassistant/components/freebox/translations/de.json index 738b9d48f3c..50644a87982 100644 --- a/homeassistant/components/freebox/translations/de.json +++ b/homeassistant/components/freebox/translations/de.json @@ -10,7 +10,7 @@ }, "step": { "link": { - "description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)", + "description": "Klicke auf \"Senden\" und ber\u00fchre dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router](/static/images/config_freebox.png)", "title": "Link Freebox Router" }, "user": { diff --git a/homeassistant/components/freedompro/__init__.py b/homeassistant/components/freedompro/__init__.py index 47c0bda1b1c..40d440d83eb 100644 --- a/homeassistant/components/freedompro/__init__.py +++ b/homeassistant/components/freedompro/__init__.py @@ -14,7 +14,16 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["light"] +PLATFORMS = [ + "binary_sensor", + "climate", + "cover", + "fan", + "light", + "lock", + "sensor", + "switch", +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py new file mode 100644 index 00000000000..133f64019c2 --- /dev/null +++ b/homeassistant/components/freedompro/binary_sensor.py @@ -0,0 +1,79 @@ +"""Support for Freedompro binary_sensor.""" +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_SMOKE, + BinarySensorEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "smokeSensor": DEVICE_CLASS_SMOKE, + "occupancySensor": DEVICE_CLASS_OCCUPANCY, + "motionSensor": DEVICE_CLASS_MOTION, + "contactSensor": DEVICE_CLASS_OPENING, +} + +DEVICE_KEY_MAP = { + "smokeSensor": "smokeDetected", + "occupancySensor": "occupancyDetected", + "motionSensor": "motionDetected", + "contactSensor": "contactSensorState", +} + +SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactSensor"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro binary_sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, BinarySensorEntity): + """Representation of an Freedompro binary_sensor.""" + + def __init__(self, device, coordinator): + """Initialize the Freedompro binary_sensor.""" + super().__init__(coordinator) + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_is_on = state[DEVICE_KEY_MAP[self._type]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py new file mode 100644 index 00000000000..e37ae9dea1b --- /dev/null +++ b/homeassistant/components/freedompro/climate.py @@ -0,0 +1,138 @@ +"""Support for Freedompro climate.""" +import json +import logging + +from pyfreedompro import put_state + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_HVAC_MODE, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +HVAC_MAP = { + 0: HVAC_MODE_OFF, + 1: HVAC_MODE_HEAT, + 2: HVAC_MODE_COOL, +} + +HVAC_INVERT_MAP = {v: k for k, v in HVAC_MAP.items()} + +SUPPORTED_HVAC_MODES = [HVAC_MODE_OFF, HVAC_MODE_HEAT, HVAC_MODE_COOL] + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro climate.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device( + aiohttp_client.async_get_clientsession(hass), api_key, device, coordinator + ) + for device in coordinator.data + if device["type"] == "thermostat" + ) + + +class Device(CoordinatorEntity, ClimateEntity): + """Representation of an Freedompro climate.""" + + _attr_hvac_modes = SUPPORTED_HVAC_MODES + _attr_temperature_unit = TEMP_CELSIUS + + def __init__(self, session, api_key, device, coordinator): + """Initialize the Freedompro climate.""" + super().__init__(coordinator) + self._session = session + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._characteristics = device["characteristics"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_current_temperature = 0 + self._attr_target_temperature = 0 + self._attr_hvac_mode = HVAC_MODE_OFF + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self._attr_unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "currentTemperature" in state: + self._attr_current_temperature = state["currentTemperature"] + if "targetTemperature" in state: + self._attr_target_temperature = state["targetTemperature"] + if "heatingCoolingState" in state: + self._attr_hvac_mode = HVAC_MAP[state["heatingCoolingState"]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_set_hvac_mode(self, hvac_mode): + """Async function to set mode to climate.""" + if hvac_mode not in SUPPORTED_HVAC_MODES: + raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") + + payload = {} + payload["heatingCoolingState"] = HVAC_INVERT_MAP[hvac_mode] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_set_temperature(self, **kwargs): + """Async function to set temperarture to climate.""" + payload = {} + if ATTR_HVAC_MODE in kwargs: + if kwargs[ATTR_HVAC_MODE] not in SUPPORTED_HVAC_MODES: + _LOGGER.error( + "Got unsupported hvac_mode %s, expected one of %s", + kwargs[ATTR_HVAC_MODE], + SUPPORTED_HVAC_MODES, + ) + return + payload["heatingCoolingState"] = HVAC_INVERT_MAP[kwargs[ATTR_HVAC_MODE]] + if ATTR_TEMPERATURE in kwargs: + payload["targetTemperature"] = kwargs[ATTR_TEMPERATURE] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py new file mode 100644 index 00000000000..439887c9626 --- /dev/null +++ b/homeassistant/components/freedompro/cover.py @@ -0,0 +1,117 @@ +"""Support for Freedompro cover.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_GATE, + DEVICE_CLASS_WINDOW, + SUPPORT_CLOSE, + SUPPORT_OPEN, + SUPPORT_SET_POSITION, + CoverEntity, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "windowCovering": DEVICE_CLASS_BLIND, + "gate": DEVICE_CLASS_GATE, + "garageDoor": DEVICE_CLASS_GARAGE, + "door": DEVICE_CLASS_DOOR, + "window": DEVICE_CLASS_WINDOW, +} + +SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro cover.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, CoverEntity): + """Representation of an Freedompro cover.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro cover.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_current_cover_position = 0 + self._attr_is_closed = True + self._attr_supported_features = ( + SUPPORT_CLOSE | SUPPORT_OPEN | SUPPORT_SET_POSITION + ) + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "position" in state: + self._attr_current_cover_position = state["position"] + if self._attr_current_cover_position == 0: + self._attr_is_closed = True + else: + self._attr_is_closed = False + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self.async_set_cover_position(position=100) + + async def async_close_cover(self, **kwargs): + """Close the cover.""" + await self.async_set_cover_position(position=0) + + async def async_set_cover_position(self, **kwargs): + """Async function to set position to cover.""" + payload = {} + payload["position"] = kwargs[ATTR_POSITION] + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py new file mode 100644 index 00000000000..55955042804 --- /dev/null +++ b/homeassistant/components/freedompro/fan.py @@ -0,0 +1,124 @@ +"""Support for Freedompro fan.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.fan import SUPPORT_SET_SPEED, FanEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro fan.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + FreedomproFan(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "fan" + ) + + +class FreedomproFan(CoordinatorEntity, FanEntity): + """Representation of an Freedompro fan.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro fan.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._characteristics = device["characteristics"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_is_on = False + self._attr_percentage = 0 + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._attr_is_on + + @property + def percentage(self): + """Return the current speed percentage.""" + return self._attr_percentage + + @property + def supported_features(self): + """Flag supported features.""" + if "rotationSpeed" in self._characteristics: + return SUPPORT_SET_SPEED + return 0 + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_is_on = state["on"] + if "rotationSpeed" in state: + self._attr_percentage = state["rotationSpeed"] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on( + self, speed=None, percentage=None, preset_mode=None, **kwargs + ): + """Async function to turn on the fan.""" + payload = {"on": True} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Async function to turn off the fan.""" + payload = {"on": False} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int): + """Set the speed percentage of the fan.""" + rotation_speed = {"rotationSpeed": percentage} + payload = json.dumps(rotation_speed) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py index ca96dba00f7..0b944a682d4 100644 --- a/homeassistant/components/freedompro/light.py +++ b/homeassistant/components/freedompro/light.py @@ -36,19 +36,24 @@ class Device(CoordinatorEntity, LightEntity): def __init__(self, hass, api_key, device, coordinator): """Initialize the Freedompro light.""" super().__init__(coordinator) - self._hass = hass - self._session = aiohttp_client.async_get_clientsession(self._hass) + self._session = aiohttp_client.async_get_clientsession(hass) self._api_key = api_key self._attr_name = device["name"] self._attr_unique_id = device["uid"] - self._type = device["type"] - self._characteristics = device["characteristics"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } self._attr_is_on = False self._attr_brightness = 0 color_mode = COLOR_MODE_ONOFF - if "hue" in self._characteristics: + if "hue" in device["characteristics"]: color_mode = COLOR_MODE_HS - elif "brightness" in self._characteristics: + elif "brightness" in device["characteristics"]: color_mode = COLOR_MODE_BRIGHTNESS self._attr_color_mode = color_mode self._attr_supported_color_modes = {color_mode} diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py new file mode 100644 index 00000000000..f3a689016f6 --- /dev/null +++ b/homeassistant/components/freedompro/lock.py @@ -0,0 +1,95 @@ +"""Support for Freedompro lock.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.lock import LockEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro lock.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "lock" + ) + + +class Device(CoordinatorEntity, LockEntity): + """Representation of an Freedompro lock.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro lock.""" + super().__init__(coordinator) + self._hass = hass + self._session = aiohttp_client.async_get_clientsession(self._hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._characteristics = device["characteristics"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": self._type, + "manufacturer": "Freedompro", + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "lock" in state: + if state["lock"] == 1: + self._attr_is_locked = True + else: + self._attr_is_locked = False + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_lock(self, **kwargs): + """Async function to lock the lock.""" + payload = {"lock": 1} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_unlock(self, **kwargs): + """Async function to unlock the lock.""" + payload = {"lock": 0} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py new file mode 100644 index 00000000000..0c12f20849c --- /dev/null +++ b/homeassistant/components/freedompro/sensor.py @@ -0,0 +1,89 @@ +"""Support for Freedompro sensor.""" +from homeassistant.components.sensor import ( + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.const import LIGHT_LUX, PERCENTAGE, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + +DEVICE_CLASS_MAP = { + "temperatureSensor": DEVICE_CLASS_TEMPERATURE, + "humiditySensor": DEVICE_CLASS_HUMIDITY, + "lightSensor": DEVICE_CLASS_ILLUMINANCE, +} +STATE_CLASS_MAP = { + "temperatureSensor": STATE_CLASS_MEASUREMENT, + "humiditySensor": STATE_CLASS_MEASUREMENT, + "lightSensor": None, +} +UNIT_MAP = { + "temperatureSensor": TEMP_CELSIUS, + "humiditySensor": PERCENTAGE, + "lightSensor": LIGHT_LUX, +} +DEVICE_KEY_MAP = { + "temperatureSensor": "currentTemperature", + "humiditySensor": "currentRelativeHumidity", + "lightSensor": "currentAmbientLightLevel", +} +SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro sensor.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(device, coordinator) + for device in coordinator.data + if device["type"] in SUPPORTED_SENSORS + ) + + +class Device(CoordinatorEntity, SensorEntity): + """Representation of an Freedompro sensor.""" + + def __init__(self, device, coordinator): + """Initialize the Freedompro sensor.""" + super().__init__(coordinator) + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._type = device["type"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_device_class = DEVICE_CLASS_MAP[device["type"]] + self._attr_state_class = STATE_CLASS_MAP[device["type"]] + self._attr_unit_of_measurement = UNIT_MAP[device["type"]] + self._attr_state = 0 + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + self._attr_state = state[DEVICE_KEY_MAP[self._type]] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py new file mode 100644 index 00000000000..c4c6b8ec353 --- /dev/null +++ b/homeassistant/components/freedompro/switch.py @@ -0,0 +1,90 @@ +"""Support for Freedompro switch.""" +import json + +from pyfreedompro import put_state + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up Freedompro switch.""" + api_key = entry.data[CONF_API_KEY] + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + Device(hass, api_key, device, coordinator) + for device in coordinator.data + if device["type"] == "switch" or device["type"] == "outlet" + ) + + +class Device(CoordinatorEntity, SwitchEntity): + """Representation of an Freedompro switch.""" + + def __init__(self, hass, api_key, device, coordinator): + """Initialize the Freedompro switch.""" + super().__init__(coordinator) + self._session = aiohttp_client.async_get_clientsession(hass) + self._api_key = api_key + self._attr_name = device["name"] + self._attr_unique_id = device["uid"] + self._attr_device_info = { + "name": self.name, + "identifiers": { + (DOMAIN, self.unique_id), + }, + "model": device["type"], + "manufacturer": "Freedompro", + } + self._attr_is_on = False + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + device = next( + ( + device + for device in self.coordinator.data + if device["uid"] == self.unique_id + ), + None, + ) + if device is not None and "state" in device: + state = device["state"] + if "on" in state: + self._attr_is_on = state["on"] + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + async def async_turn_on(self, **kwargs): + """Async function to set on to switch.""" + payload = {"on": True} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs): + """Async function to set off to switch.""" + payload = {"on": False} + payload = json.dumps(payload) + await put_state( + self._session, + self._api_key, + self.unique_id, + payload, + ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/freedompro/translations/ar.json b/homeassistant/components/freedompro/translations/ar.json new file mode 100644 index 00000000000..799f812ecca --- /dev/null +++ b/homeassistant/components/freedompro/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "\u0627\u0644\u0631\u062c\u0627\u0621 \u0625\u062f\u062e\u0627\u0644 \u0645\u0641\u062a\u0627\u062d API \u0627\u0644\u0630\u064a \u062a\u0645 \u0627\u0644\u062d\u0635\u0648\u0644 \u0639\u0644\u064a\u0647 \u0645\u0646 https://home.freedompro.eu", + "title": "\u0645\u0641\u062a\u0627\u062d Freedompro API" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/ca.json b/homeassistant/components/freedompro/translations/ca.json new file mode 100644 index 00000000000..29fc97d35ff --- /dev/null +++ b/homeassistant/components/freedompro/translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API" + }, + "description": "Introdueix la clau API obtinguda de https://home.freedompro.eu", + "title": "Clau API de Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/de.json b/homeassistant/components/freedompro/translations/de.json new file mode 100644 index 00000000000..7ac985baeee --- /dev/null +++ b/homeassistant/components/freedompro/translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel" + }, + "description": "Bitte gib den API-Schl\u00fcssel ein, den du von https://home.freedompro.eu erhalten hast.", + "title": "Freedompro API-Schl\u00fcssel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/en.json b/homeassistant/components/freedompro/translations/en.json index c8952d56bfd..83c36c43b64 100644 --- a/homeassistant/components/freedompro/translations/en.json +++ b/homeassistant/components/freedompro/translations/en.json @@ -17,4 +17,4 @@ } } } -} +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/es.json b/homeassistant/components/freedompro/translations/es.json new file mode 100644 index 00000000000..b6f8afeaf6d --- /dev/null +++ b/homeassistant/components/freedompro/translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API" + }, + "description": "Ingrese la clave API obtenida de https://home.freedompro.eu", + "title": "Clave API de Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/et.json b/homeassistant/components/freedompro/translations/et.json new file mode 100644 index 00000000000..16e5f414264 --- /dev/null +++ b/homeassistant/components/freedompro/translations/et.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "user": { + "data": { + "api_key": "API v\u00f5ti" + }, + "description": "Sisesta aadressilt https://home.freedompro.eu saadud API v\u00f5ti", + "title": "Freedompro API v\u00f5ti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/fr.json b/homeassistant/components/freedompro/translations/fr.json new file mode 100644 index 00000000000..6667226a206 --- /dev/null +++ b/homeassistant/components/freedompro/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification invalide" + }, + "step": { + "user": { + "data": { + "api_key": "cl\u00e9 API" + }, + "description": "Veuillez saisir la cl\u00e9 API obtenue sur https://home.freedompro.eu", + "title": "Cl\u00e9 API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/he.json b/homeassistant/components/freedompro/translations/he.json new file mode 100644 index 00000000000..b4bb1b26bfe --- /dev/null +++ b/homeassistant/components/freedompro/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/hu.json b/homeassistant/components/freedompro/translations/hu.json new file mode 100644 index 00000000000..e56cc7a4a41 --- /dev/null +++ b/homeassistant/components/freedompro/translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "api_key": "API kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a https://home.freedompro.eu webhelyr\u0151l kapott API-kulcsot", + "title": "Freedompro API kulcs" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/id.json b/homeassistant/components/freedompro/translations/id.json new file mode 100644 index 00000000000..82523dc65d1 --- /dev/null +++ b/homeassistant/components/freedompro/translations/id.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "Kunci API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/it.json b/homeassistant/components/freedompro/translations/it.json new file mode 100644 index 00000000000..51dfa372f17 --- /dev/null +++ b/homeassistant/components/freedompro/translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API" + }, + "description": "Inserisci la chiave API ottenuta da https://home.freedompro.eu", + "title": "Chiave API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/nl.json b/homeassistant/components/freedompro/translations/nl.json new file mode 100644 index 00000000000..8bf5e60d937 --- /dev/null +++ b/homeassistant/components/freedompro/translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "api_key": "API-sleutel" + }, + "description": "Voer de API-sleutel in die is verkregen van https://home.freedompro.eu", + "title": "Freedompro API key" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/no.json b/homeassistant/components/freedompro/translations/no.json new file mode 100644 index 00000000000..39a3e339d9a --- /dev/null +++ b/homeassistant/components/freedompro/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel" + }, + "description": "Vennligst skriv inn API-n\u00f8kkelen hentet fra https://home.freedompro.eu", + "title": "Freedompro API-n\u00f8kkel" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/pl.json b/homeassistant/components/freedompro/translations/pl.json new file mode 100644 index 00000000000..62985add95a --- /dev/null +++ b/homeassistant/components/freedompro/translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API" + }, + "description": "Wprowad\u017a klucz API uzyskany z https://home.freedompro.eu", + "title": "Klucz API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/ru.json b/homeassistant/components/freedompro/translations/ru.json new file mode 100644 index 00000000000..db1523bfecb --- /dev/null +++ b/homeassistant/components/freedompro/translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043b\u044e\u0447 API, \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043d\u0430 https://home.freedompro.eu", + "title": "\u041a\u043b\u044e\u0447 API Freedompro" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freedompro/translations/zh-Hant.json b/homeassistant/components/freedompro/translations/zh-Hant.json new file mode 100644 index 00000000000..2baa8719e2e --- /dev/null +++ b/homeassistant/components/freedompro/translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165\u7531 https://home.freedompro.eu \u6240\u7372\u5f97\u7684 API \u5bc6\u9470", + "title": "Freedompro API \u5bc6\u9470" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 332c9b795f8..2a9a5e5cd2e 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -15,7 +15,6 @@ from fritzconnection.core.exceptions import ( ) from fritzconnection.lib.fritzhosts import FritzHosts from fritzconnection.lib.fritzstatus import FritzStatus -from fritzprofiles import FritzProfileSwitch, get_all_profiles from homeassistant.components.device_tracker.const import ( CONF_CONSIDER_HOME, @@ -24,12 +23,13 @@ from homeassistant.components.device_tracker.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import dt as dt_util from .const import ( + DEFAULT_DEVICE_NAME, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USERNAME, @@ -81,12 +81,11 @@ class FritzBoxTools: ) -> None: """Initialize FritzboxTools class.""" self._cancel_scan: CALLBACK_TYPE | None = None - self._devices: dict[str, Any] = {} + self._devices: dict[str, FritzDevice] = {} self._options: MappingProxyType[str, Any] | None = None self._unique_id: str | None = None self.connection: FritzConnection = None self.fritz_hosts: FritzHosts = None - self.fritz_profiles: dict[str, FritzProfileSwitch] = {} self.fritz_status: FritzStatus = None self.hass = hass self.host = host @@ -109,8 +108,13 @@ class FritzBoxTools: user=self.username, password=self.password, timeout=60.0, + pool_maxsize=30, ) + if not self.connection: + _LOGGER.error("Unable to establish a connection with %s", self.host) + return + self.fritz_status = FritzStatus(fc=self.connection) info = self.connection.call_action("DeviceInfo:1", "GetInfo") if not self._unique_id: @@ -119,13 +123,6 @@ class FritzBoxTools: self._model = info.get("NewModelName") self._sw_version = info.get("NewSoftwareVersion") - self.fritz_profiles = { - profile: FritzProfileSwitch( - "http://" + self.host, self.username, self.password, profile - ) - for profile in get_all_profiles(self.host, self.username, self.password) - } - async def async_start(self, options: MappingProxyType[str, Any]) -> None: """Start FritzHosts connection.""" self.fritz_hosts = FritzHosts(fc=self.connection) @@ -189,7 +186,7 @@ class FritzBoxTools: def _update_info(self) -> list[HostInfo]: """Retrieve latest information from the FRITZ!Box.""" - return self.fritz_hosts.get_hosts_info() + return self.fritz_hosts.get_hosts_info() # type: ignore [no-any-return] def scan_devices(self, now: datetime | None = None) -> None: """Scan for new devices and return a list of found device ids.""" @@ -256,10 +253,92 @@ class FritzData: """Storage class for platform global data.""" tracked: dict = field(default_factory=dict) + profile_switches: dict = field(default_factory=dict) + + +class FritzDeviceBase(Entity): + """Entity base class for a device connected to a FRITZ!Box router.""" + + def __init__(self, router: FritzBoxTools, device: FritzDevice) -> None: + """Initialize a FRITZ!Box device.""" + self._router = router + 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: + device: FritzDevice = self._router.devices[self._mac] + return device.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: + device: FritzDevice = self._router.devices[self._mac] + return device.hostname + return None + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return { + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + "identifiers": {(DOMAIN, self._mac)}, + "default_name": self.name, + "default_manufacturer": "AVM", + "default_model": "FRITZ!Box Tracked device", + "via_device": ( + DOMAIN, + self._router.unique_id, + ), + } + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False + + 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() + + async def async_added_to_hass(self) -> None: + """Register state update callback.""" + await self.async_process_update() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self._router.signal_device_update, + self.async_on_demand_update, + ) + ) class FritzDevice: - """FritzScanner device.""" + """Representation of a device connected to the FRITZ!Box.""" def __init__(self, mac: str, name: str) -> None: """Initialize device info.""" @@ -288,7 +367,7 @@ class FritzDevice: if dev_home: self._last_activity = utc_point_in_time - self._ip_address = dev_info.ip_address if self._connected else None + self._ip_address = dev_info.ip_address @property def is_connected(self) -> bool: diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 776c7a7dafa..8b3f9106602 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -19,11 +19,7 @@ FRITZ_SERVICES = "fritz_services" SERVICE_REBOOT = "reboot" SERVICE_RECONNECT = "reconnect" -SWITCH_PROFILE_STATUS_OFF = "never" -SWITCH_PROFILE_STATUS_ON = "unlimited" - SWITCH_TYPE_DEFLECTION = "CallDeflection" -SWITCH_TYPE_DEVICEPROFILE = "DeviceProfile" SWITCH_TYPE_PORTFORWARD = "PortForward" SWITCH_TYPE_WIFINETWORK = "WiFiNetwork" diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index d4ff1dbd161..e18ec8005cc 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -16,14 +16,12 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .common import Device, FritzBoxTools, FritzData, FritzDevice -from .const import DATA_FRITZ, DEFAULT_DEVICE_NAME, DOMAIN +from .common import FritzBoxTools, FritzData, FritzDevice, FritzDeviceBase +from .const import DATA_FRITZ, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -93,7 +91,7 @@ def _async_add_entities( ) -> None: """Add new tracker entities from the router.""" - def _is_tracked(mac: str, device: Device) -> bool: + def _is_tracked(mac: str) -> bool: for tracked in data_fritz.tracked.values(): if mac in tracked: return True @@ -105,7 +103,7 @@ def _async_add_entities( data_fritz.tracked[router.unique_id] = set() for mac, device in router.devices.items(): - if device.ip_address == "" or _is_tracked(mac, device): + if device.ip_address == "" or _is_tracked(mac): continue new_tracked.append(FritzBoxTracker(router, device)) @@ -115,14 +113,12 @@ def _async_add_entities( async_add_entities(new_tracked) -class FritzBoxTracker(ScannerEntity): +class FritzBoxTracker(FritzDeviceBase, ScannerEntity): """This class queries a FRITZ!Box router.""" def __init__(self, router: FritzBoxTools, device: FritzDevice) -> None: """Initialize a FRITZ!Box device.""" - self._router = router - self._mac: str = device.mac_address - self._name: str = device.hostname or DEFAULT_DEVICE_NAME + super().__init__(router, device) self._last_activity: datetime.datetime | None = device.last_activity self._active = False @@ -131,59 +127,10 @@ class FritzBoxTracker(ScannerEntity): """Return device status.""" return self._active - @property - def name(self) -> str: - """Return device name.""" - return self._name - @property def unique_id(self) -> str: """Return device unique id.""" - return self._mac - - @property - def ip_address(self) -> str | None: - """Return the primary ip address of the device.""" - if self._mac: - return self._router.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._router.devices[self._mac].hostname - return None - - @property - def source_type(self) -> str: - """Return tracker source type.""" - return SOURCE_TYPE_ROUTER - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - "identifiers": {(DOMAIN, self.unique_id)}, - "default_name": self.name, - "default_manufacturer": "AVM", - "default_model": "FRITZ!Box Tracked device", - "via_device": ( - DOMAIN, - self._router.unique_id, - ), - } - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False + return f"{self._mac}_tracker" @property def icon(self) -> str: @@ -192,11 +139,6 @@ class FritzBoxTracker(ScannerEntity): return "mdi:lan-connect" return "mdi:lan-disconnect" - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - @property def extra_state_attributes(self) -> dict[str, str]: """Return the attributes.""" @@ -207,8 +149,12 @@ class FritzBoxTracker(ScannerEntity): ) return attrs - @callback - def async_process_update(self) -> None: + @property + def source_type(self) -> str: + """Return tracker source type.""" + return SOURCE_TYPE_ROUTER + + async def async_process_update(self) -> None: """Update device.""" if not self._mac: return @@ -216,20 +162,3 @@ class FritzBoxTracker(ScannerEntity): device = self._router.devices[self._mac] self._active = device.is_connected self._last_activity = device.last_activity - - @callback - def async_on_demand_update(self) -> None: - """Update state.""" - self.async_process_update() - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register state update callback.""" - self.async_process_update() - self.async_on_remove( - async_dispatcher_connect( - self.hass, - self._router.signal_device_update, - self.async_on_demand_update, - ) - ) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index d1c096a2ef5..46531183afd 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -3,8 +3,7 @@ "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", "requirements": [ - "fritzconnection==1.4.2", - "fritzprofiles==0.6.1", + "fritzconnection==1.6.0", "xmltodict==0.12.0" ], "dependencies": ["network"], diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 6d3a8f33c3c..482d5b1d688 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -8,9 +8,14 @@ from typing import Callable, TypedDict from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import ( + DATA_GIGABYTES, + DATA_RATE_KILOBITS_PER_SECOND, + DATA_RATE_KILOBYTES_PER_SECOND, + DEVICE_CLASS_TIMESTAMP, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow @@ -21,9 +26,9 @@ from .const import DOMAIN, UPTIME_DEVIATION _LOGGER = logging.getLogger(__name__) -def _retrieve_uptime_state(status: FritzStatus, last_value: str) -> str: - """Return uptime from device.""" - delta_uptime = utcnow() - datetime.timedelta(seconds=status.uptime) +def _uptime_calculation(seconds_uptime: float, last_value: str | None) -> str: + """Calculate uptime with deviation.""" + delta_uptime = utcnow() - datetime.timedelta(seconds=seconds_uptime) if ( not last_value @@ -37,16 +42,61 @@ def _retrieve_uptime_state(status: FritzStatus, last_value: str) -> str: return last_value +def _retrieve_device_uptime_state(status: FritzStatus, last_value: str) -> str: + """Return uptime from device.""" + return _uptime_calculation(status.device_uptime, last_value) + + +def _retrieve_connection_uptime_state( + status: FritzStatus, last_value: str | None +) -> str: + """Return uptime from connection.""" + return _uptime_calculation(status.connection_uptime, last_value) + + def _retrieve_external_ip_state(status: FritzStatus, last_value: str) -> str: """Return external ip from device.""" - return status.external_ip + return status.external_ip # type: ignore[no-any-return] -class SensorData(TypedDict): +def _retrieve_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload transmission rate.""" + return round(status.transmission_rate[0] / 1024, 1) # type: ignore[no-any-return] + + +def _retrieve_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download transmission rate.""" + return round(status.transmission_rate[1] / 1024, 1) # type: ignore[no-any-return] + + +def _retrieve_max_kb_s_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload max transmission rate.""" + return round(status.max_bit_rate[0] / 1024, 1) # type: ignore[no-any-return] + + +def _retrieve_max_kb_s_received_state(status: FritzStatus, last_value: str) -> float: + """Return download max transmission rate.""" + return round(status.max_bit_rate[1] / 1024, 1) # type: ignore[no-any-return] + + +def _retrieve_gb_sent_state(status: FritzStatus, last_value: str) -> float: + """Return upload total data.""" + return round(status.bytes_sent * 8 / 1024 / 1024 / 1024, 1) # type: ignore[no-any-return] + + +def _retrieve_gb_received_state(status: FritzStatus, last_value: str) -> float: + """Return download total data.""" + return round(status.bytes_received * 8 / 1024 / 1024 / 1024, 1) # type: ignore[no-any-return] + + +class SensorData(TypedDict, total=False): """Sensor data class.""" name: str device_class: str | None + state_class: str | None + last_reset: bool + unit_of_measurement: str | None icon: str | None state_provider: Callable @@ -54,15 +104,60 @@ class SensorData(TypedDict): SENSOR_DATA = { "external_ip": SensorData( name="External IP", - device_class=None, icon="mdi:earth", state_provider=_retrieve_external_ip_state, ), - "uptime": SensorData( - name="Uptime", + "device_uptime": SensorData( + name="Device Uptime", device_class=DEVICE_CLASS_TIMESTAMP, - icon=None, - state_provider=_retrieve_uptime_state, + state_provider=_retrieve_device_uptime_state, + ), + "connection_uptime": SensorData( + name="Connection Uptime", + device_class=DEVICE_CLASS_TIMESTAMP, + state_provider=_retrieve_connection_uptime_state, + ), + "kb_s_sent": SensorData( + name="kB/s sent", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:upload", + state_provider=_retrieve_kb_s_sent_state, + ), + "kb_s_received": SensorData( + name="kB/s received", + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=DATA_RATE_KILOBYTES_PER_SECOND, + icon="mdi:download", + state_provider=_retrieve_kb_s_received_state, + ), + "max_kb_s_sent": SensorData( + name="Max kbit/s sent", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:upload", + state_provider=_retrieve_max_kb_s_sent_state, + ), + "max_kb_s_received": SensorData( + name="Max kbit/s received", + unit_of_measurement=DATA_RATE_KILOBITS_PER_SECOND, + icon="mdi:download", + state_provider=_retrieve_max_kb_s_received_state, + ), + "gb_sent": SensorData( + name="GB sent", + state_class=STATE_CLASS_MEASUREMENT, + last_reset=True, + unit_of_measurement=DATA_GIGABYTES, + icon="mdi:upload", + state_provider=_retrieve_gb_sent_state, + ), + "gb_received": SensorData( + name="GB received", + state_class=STATE_CLASS_MEASUREMENT, + last_reset=True, + unit_of_measurement=DATA_GIGABYTES, + icon="mdi:download", + state_provider=_retrieve_gb_received_state, ), } @@ -97,11 +192,15 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): ) -> None: """Init FRITZ!Box connectivity class.""" self._sensor_data: SensorData = SENSOR_DATA[sensor_type] - self._unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" - self._name = f"{device_friendly_name} {self._sensor_data['name']}" - self._is_available = True - self._last_value: str | None = None - self._state: str | None = None + self._last_device_value: str | None = None + self._last_wan_value: str | None = None + self._attr_available = True + self._attr_device_class = self._sensor_data.get("device_class") + self._attr_icon = self._sensor_data.get("icon") + self._attr_name = f"{device_friendly_name} {self._sensor_data['name']}" + self._attr_state_class = self._sensor_data.get("state_class") + self._attr_unit_of_measurement = self._sensor_data.get("unit_of_measurement") + self._attr_unique_id = f"{fritzbox_tools.unique_id}-{sensor_type}" super().__init__(fritzbox_tools, device_friendly_name) @property @@ -109,46 +208,27 @@ class FritzBoxSensor(FritzBoxBaseEntity, SensorEntity): """Return the state provider for the binary sensor.""" return self._sensor_data["state_provider"] - @property - def name(self) -> str: - """Return name.""" - return self._name - - @property - def device_class(self) -> str | None: - """Return device class.""" - return self._sensor_data["device_class"] - - @property - def icon(self) -> str | None: - """Return icon.""" - return self._sensor_data["icon"] - - @property - def unique_id(self) -> str: - """Return unique id.""" - return self._unique_id - - @property - def state(self) -> str | None: - """Return the state of the sensor.""" - return self._state - - @property - def available(self) -> bool: - """Return availability.""" - return self._is_available - def update(self) -> None: """Update data.""" _LOGGER.debug("Updating FRITZ!Box sensors") try: status: FritzStatus = self._fritzbox_tools.fritz_status - self._is_available = True + self._attr_available = True except FritzConnectionException: _LOGGER.error("Error getting the state from the FRITZ!Box", exc_info=True) - self._is_available = False + self._attr_available = False return - self._state = self._last_value = self._state_provider(status, self._last_value) + self._attr_state = self._last_device_value = self._state_provider( + status, self._last_device_value + ) + + if self._sensor_data.get("last_reset") is True: + self._last_wan_value = _retrieve_connection_uptime_state( + status, self._last_wan_value + ) + self._attr_last_reset = datetime.datetime.strptime( + self._last_wan_value, + "%Y-%m-%dT%H:%M:%S%z", + ) diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index fcfbd54b743..359e7ced239 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" - for service in [SERVICE_REBOOT, SERVICE_RECONNECT]: + for service in (SERVICE_REBOOT, SERVICE_RECONNECT): if hass.services.has_service(DOMAIN, service): return @@ -34,7 +34,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: fritz_tools = hass.data[DOMAIN][entry] await fritz_tools.service_fritzbox(service_call.service) - for service in [SERVICE_REBOOT, SERVICE_RECONNECT]: + for service in (SERVICE_REBOOT, SERVICE_RECONNECT): hass.services.async_register(DOMAIN, service, async_call_fritz_service) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 16eaecb178d..10eb6553dbd 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -13,22 +13,30 @@ from fritzconnection.core.exceptions import ( FritzSecurityError, FritzServiceError, ) +import slugify as unicode_slug import xmltodict +from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import get_local_ip, slugify +from homeassistant.util import slugify -from .common import FritzBoxBaseEntity, FritzBoxTools, SwitchInfo +from .common import ( + FritzBoxBaseEntity, + FritzBoxTools, + FritzData, + FritzDevice, + FritzDeviceBase, + SwitchInfo, +) from .const import ( + DATA_FRITZ, DOMAIN, - SWITCH_PROFILE_STATUS_OFF, - SWITCH_PROFILE_STATUS_ON, SWITCH_TYPE_DEFLECTION, - SWITCH_TYPE_DEVICEPROFILE, SWITCH_TYPE_PORTFORWARD, SWITCH_TYPE_WIFINETWORK, ) @@ -69,7 +77,7 @@ def service_call_action( return None try: - return fritzbox_tools.connection.call_action( + return fritzbox_tools.connection.call_action( # type: ignore[no-any-return] f"{service_name}:{service_suffix}", action_name, **kwargs, @@ -154,7 +162,7 @@ def deflection_entities_list( def port_entities_list( - fritzbox_tools: FritzBoxTools, device_friendly_name: str + fritzbox_tools: FritzBoxTools, device_friendly_name: str, local_ip: str ) -> list[FritzBoxPortSwitch]: """Get list of port forwarding entities.""" @@ -187,7 +195,6 @@ def port_entities_list( port_forwards_count, ) - local_ip = get_local_ip() _LOGGER.debug("IP source for %s is %s", fritzbox_tools.host, local_ip) for i in range(port_forwards_count): @@ -225,21 +232,6 @@ def port_entities_list( return entities_list -def profile_entities_list( - fritzbox_tools: FritzBoxTools, device_friendly_name: str -) -> list[FritzBoxProfileSwitch]: - """Get list of profile entities.""" - _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEVICEPROFILE) - if len(fritzbox_tools.fritz_profiles) <= 0: - _LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEVICEPROFILE) - return [] - - return [ - FritzBoxProfileSwitch(fritzbox_tools, device_friendly_name, profile) - for profile in fritzbox_tools.fritz_profiles.keys() - ] - - def wifi_entities_list( fritzbox_tools: FritzBoxTools, device_friendly_name: str ) -> list[FritzBoxWifiSwitch]: @@ -256,26 +248,59 @@ def wifi_entities_list( ) if network_info: ssid = network_info["NewSSID"] - if ssid in networks.values(): + if unicode_slug.slugify(ssid, lowercase=False) in networks.values(): networks[i] = f'{ssid} {std_table[network_info["NewStandard"]]}' else: networks[i] = ssid return [ - FritzBoxWifiSwitch(fritzbox_tools, device_friendly_name, net, networks[net]) - for net in networks + FritzBoxWifiSwitch(fritzbox_tools, device_friendly_name, net, network_name) + for net, network_name in networks.items() ] +def profile_entities_list( + router: FritzBoxTools, data_fritz: FritzData +) -> list[FritzBoxProfileSwitch]: + """Add new tracker entities from the router.""" + + def _is_tracked(mac: str) -> bool: + for tracked in data_fritz.profile_switches.values(): + if mac in tracked: + return True + + return False + + new_profiles: list[FritzBoxProfileSwitch] = [] + + if "X_AVM-DE_HostFilter1" not in router.connection.services: + return new_profiles + + if router.unique_id not in data_fritz.profile_switches: + data_fritz.profile_switches[router.unique_id] = set() + + for mac, device in router.devices.items(): + if device.ip_address == "" or _is_tracked(mac): + continue + + new_profiles.append(FritzBoxProfileSwitch(router, device)) + data_fritz.profile_switches[router.unique_id].add(mac) + + return new_profiles + + def all_entities_list( - fritzbox_tools: FritzBoxTools, device_friendly_name: str + fritzbox_tools: FritzBoxTools, + device_friendly_name: str, + data_fritz: FritzData, + local_ip: str, ) -> list[Entity]: """Get a list of all entities.""" return [ *deflection_entities_list(fritzbox_tools, device_friendly_name), - *port_entities_list(fritzbox_tools, device_friendly_name), - *profile_entities_list(fritzbox_tools, device_friendly_name), + *port_entities_list(fritzbox_tools, device_friendly_name, local_ip), *wifi_entities_list(fritzbox_tools, device_friendly_name), + *profile_entities_list(fritzbox_tools, data_fritz), ] @@ -285,14 +310,29 @@ async def async_setup_entry( """Set up entry.""" _LOGGER.debug("Setting up switches") fritzbox_tools: FritzBoxTools = hass.data[DOMAIN][entry.entry_id] + data_fritz: FritzData = hass.data[DATA_FRITZ] _LOGGER.debug("Fritzbox services: %s", fritzbox_tools.connection.services) - entities_list = await hass.async_add_executor_job( - all_entities_list, fritzbox_tools, entry.title + local_ip = await async_get_source_ip( + fritzbox_tools.hass, target_ip=fritzbox_tools.host ) + + entities_list = await hass.async_add_executor_job( + all_entities_list, fritzbox_tools, entry.title, data_fritz, local_ip + ) + async_add_entities(entities_list) + @callback + def update_router() -> None: + """Update the values of the router.""" + async_add_entities(profile_entities_list(fritzbox_tools, data_fritz)) + + entry.async_on_unload( + async_dispatcher_connect(hass, fritzbox_tools.signal_device_new, update_router) + ) + class FritzBoxBaseSwitch(FritzBoxBaseEntity): """Fritz switch base class.""" @@ -428,8 +468,8 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity): "NewPortMappingDescription": "description", } - for key in attributes_dict: - self._attributes[attributes_dict[key]] = self.port_mapping[key] + for key, attr in attributes_dict.items(): + self._attributes[attr] = self.port_mapping[key] async def _async_handle_port_switch_on_off(self, turn_on: bool) -> bool: @@ -522,60 +562,61 @@ class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity): ) -class FritzBoxProfileSwitch(FritzBoxBaseSwitch, SwitchEntity): +class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity): """Defines a FRITZ!Box Tools DeviceProfile switch.""" - def __init__( - self, fritzbox_tools: FritzBoxTools, device_friendly_name: str, profile: str - ) -> None: + _attr_icon = "mdi:router-wireless-settings" + + def __init__(self, fritzbox_tools: FritzBoxTools, device: FritzDevice) -> None: """Init Fritz profile.""" - self._fritzbox_tools: FritzBoxTools = fritzbox_tools - self.profile = profile + super().__init__(fritzbox_tools, device) + self._attr_is_on: bool = False + self._name = f"{device.hostname} Internet Access" + self._attr_unique_id = f"{self._mac}_internet_access" - switch_info = SwitchInfo( - description=f"Profile {profile}", - friendly_name=device_friendly_name, - icon="mdi:router-wireless-settings", - type=SWITCH_TYPE_DEVICEPROFILE, - callback_update=self._async_fetch_update, - callback_switch=self._async_switch_on_off_executor, - ) - super().__init__(self._fritzbox_tools, device_friendly_name, switch_info) + async def async_process_update(self) -> None: + """Update device.""" + if not self._mac or not self.ip_address: + return - async def _async_fetch_update(self) -> None: - """Update data.""" - try: - status = await self.hass.async_add_executor_job( - self._fritzbox_tools.fritz_profiles[self.profile].get_state - ) - _LOGGER.debug( - "Specific %s response: get_State()=%s", - SWITCH_TYPE_DEVICEPROFILE, - status, - ) - if status == SWITCH_PROFILE_STATUS_OFF: - self._attr_is_on = False - self._is_available = True - elif status == SWITCH_PROFILE_STATUS_ON: - self._attr_is_on = True - self._is_available = True - else: - self._is_available = False - except Exception: # pylint: disable=broad-except - _LOGGER.error("Could not get %s state", self.name, exc_info=True) - self._is_available = False - - async def _async_switch_on_off_executor(self, turn_on: bool) -> None: - """Handle profile switch.""" - state = SWITCH_PROFILE_STATUS_ON if turn_on else SWITCH_PROFILE_STATUS_OFF - await self.hass.async_add_executor_job( - self._fritzbox_tools.fritz_profiles[self.profile].set_state, state + wan_disable_info = await async_service_call_action( + self._router, + "X_AVM-DE_HostFilter", + "1", + "GetWANAccessByIP", + NewIPv4Address=self.ip_address, ) - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False + if wan_disable_info is None: + return + + self._attr_is_on = not wan_disable_info["NewDisallow"] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on switch.""" + await self._async_handle_turn_on_off(turn_on=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off switch.""" + await self._async_handle_turn_on_off(turn_on=False) + + async def _async_handle_turn_on_off(self, turn_on: bool) -> bool: + """Handle switch state change request.""" + await self._async_switch_on_off(turn_on) + self._attr_is_on = turn_on + self.async_write_ha_state() + return True + + async def _async_switch_on_off(self, turn_on: bool) -> None: + """Handle parental control switch.""" + await async_service_call_action( + self._router, + "X_AVM-DE_HostFilter", + "1", + "DisallowWANAccessByIP", + NewIPv4Address=self.ip_address, + NewDisallow="0" if turn_on else "1", + ) class FritzBoxWifiSwitch(FritzBoxBaseSwitch, SwitchEntity): diff --git a/homeassistant/components/fritz/translations/de.json b/homeassistant/components/fritz/translations/de.json index dcded6750e9..47938084f5b 100644 --- a/homeassistant/components/fritz/translations/de.json +++ b/homeassistant/components/fritz/translations/de.json @@ -37,7 +37,7 @@ "port": "Port", "username": "Benutzername" }, - "description": "Einrichten der FRITZ!Box Tools zur Steuerung Ihrer FRITZ!Box.\n Ben\u00f6tigt: Benutzername, Passwort.", + "description": "Einrichten der FRITZ!Box Tools zur Steuerung deiner FRITZ!Box.\nBen\u00f6tigt: Benutzername, Passwort.", "title": "Setup FRITZ!Box Tools - obligatorisch" }, "user": { @@ -47,7 +47,7 @@ "port": "Port", "username": "Benutzername" }, - "description": "FRITZ!Box Tools einrichten, um Ihre FRITZ!Box zu steuern.\nMindestens erforderlich: Benutzername, Passwort.", + "description": "FRITZ!Box Tools einrichten, um deine FRITZ!Box zu steuern.\nMindestens erforderlich: Benutzername, Passwort.", "title": "Setup FRITZ!Box Tools" } } diff --git a/homeassistant/components/fritz/translations/fr.json b/homeassistant/components/fritz/translations/fr.json index e0fa5dd3e8c..6518b5ed20c 100644 --- a/homeassistant/components/fritz/translations/fr.json +++ b/homeassistant/components/fritz/translations/fr.json @@ -1,10 +1,14 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9 ", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "connection_error": "Erreur de connexion", "invalid_auth": "Authentification invalide" }, @@ -35,6 +39,25 @@ }, "description": "Configuration de FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\nMinimum requis: nom d'utilisateur, mot de passe.", "title": "Configuration FRITZ!Box Tools - obligatoire" + }, + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "description": "Configurer FRITZ!Box Tools pour contr\u00f4ler votre FRITZ!Box.\n Minimum requis : nom d'utilisateur, mot de passe.", + "title": "Configurer les outils de FRITZ!Box" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "Secondes pour consid\u00e9rer un appareil \u00e0 la 'maison'" + } } } } diff --git a/homeassistant/components/fritz/translations/hu.json b/homeassistant/components/fritz/translations/hu.json index eda37325071..1433860bfa6 100644 --- a/homeassistant/components/fritz/translations/hu.json +++ b/homeassistant/components/fritz/translations/hu.json @@ -1,16 +1,62 @@ { "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" + }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "connection_error": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "flow_title": "{name}", "step": { + "confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "Felfedezte a FRITZ! Boxot: {name} \n\n A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa a {name}", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa" + }, + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "{host} FRITZ! Box Tools hiteles\u00edt\u0151 adatait. \n\n A FRITZ! Box Tools nem tud bejelentkezni a FRITZ! Box eszk\u00f6zbe.", + "title": "A FRITZ! Box Tools friss\u00edt\u00e9se - hiteles\u00edt\u0151 adatok" + }, + "start_config": { + "data": { + "host": "Gazdag\u00e9p", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "A FRITZ! Box eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa a FRITZ! Box vez\u00e9rl\u00e9s\u00e9hez.\n Minimum sz\u00fcks\u00e9ges: felhaszn\u00e1l\u00f3n\u00e9v, jelsz\u00f3.", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa - k\u00f6telez\u0151" + }, "user": { "data": { "host": "Hoszt", "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "description": "A FRITZ! Box eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa a FRITZ! Box vez\u00e9rl\u00e9s\u00e9hez.\n Minimum sz\u00fcks\u00e9ges: felhaszn\u00e1l\u00f3n\u00e9v, jelsz\u00f3.", + "title": "A FRITZ! Box Tools be\u00e1ll\u00edt\u00e1sa" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "consider_home": "M\u00e1sodpercek egy eszk\u00f6z \"otthon\" tart\u00e1s\u00e1ra" } } } diff --git a/homeassistant/components/fritz/translations/id.json b/homeassistant/components/fritz/translations/id.json new file mode 100644 index 00000000000..1a3140da624 --- /dev/null +++ b/homeassistant/components/fritz/translations/id.json @@ -0,0 +1,47 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "already_configured": "Perangkat sudah dikonfigurasi", + "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", + "connection_error": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "reauth_confirm": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + }, + "start_config": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + } + }, + "user": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 124719b93c1..cef325a61f3 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -6,6 +6,7 @@ from datetime import timedelta from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError import requests +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -16,10 +17,12 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -81,6 +84,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + def _update_unique_id(entry: RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if ( + entry.unit_of_measurement == TEMP_CELSIUS + and "_temperature" not in entry.unique_id + ): + new_unique_id = f"{entry.unique_id}_temperature" + LOGGER.info( + "Migrating unique_id [%s] to [%s]", entry.unique_id, new_unique_id + ) + return {"new_unique_id": new_unique_id} + return None + + await async_migrate_entries(hass, entry.entry_id, _update_unique_id) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) def logout_fritzbox(event: Event) -> None: @@ -123,6 +141,12 @@ class FritzBoxEntity(CoordinatorEntity): self._unique_id = entity_info[ATTR_ENTITY_ID] self._unit_of_measurement = entity_info[ATTR_UNIT_OF_MEASUREMENT] self._device_class = entity_info[ATTR_DEVICE_CLASS] + self._attr_state_class = entity_info[ATTR_STATE_CLASS] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.device.present @property def device(self) -> FritzhomeDevice: diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 242e3d6e644..5514408cb3c 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_WINDOW, BinarySensorEntity, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -37,6 +38,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: DEVICE_CLASS_WINDOW, + ATTR_STATE_CLASS: None, }, coordinator, ain, @@ -52,6 +54,4 @@ class FritzboxBinarySensor(FritzBoxEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - if not self.device.present: - return False return self.device.alert_state # type: ignore [no-any-return] diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index c50e0d4f270..4baa1b3b81a 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -13,6 +13,7 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, @@ -74,6 +75,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: None, }, coordinator, ain, @@ -91,11 +93,6 @@ class FritzboxThermostat(FritzBoxEntity, ClimateEntity): """Return the list of supported features.""" return SUPPORT_FLAGS - @property - def available(self) -> bool: - """Return if thermostat is available.""" - return self.device.present # type: ignore [no-any-return] - @property def temperature_unit(self) -> str: """Return the unit of measurement that is used.""" diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index af2ec30312f..6af75449a29 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -13,9 +13,6 @@ ATTR_STATE_WINDOW_OPEN: Final = "window_open" ATTR_TEMPERATURE_UNIT: Final = "temperature_unit" -ATTR_TOTAL_CONSUMPTION: Final = "total_consumption" -ATTR_TOTAL_CONSUMPTION_UNIT: Final = "total_consumption_unit" - CONF_CONNECTIONS: Final = "connections" CONF_COORDINATOR: Final = "coordinator" diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 3daecb1980d..c1db226d348 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -2,7 +2,7 @@ "domain": "fritzbox", "name": "AVM FRITZ!SmartHome", "documentation": "https://www.home-assistant.io/integrations/fritzbox", - "requirements": ["pyfritzhome==0.4.2"], + "requirements": ["pyfritzhome==0.6.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox/model.py b/homeassistant/components/fritzbox/model.py index 1cde7b9ca70..0e401a75be3 100644 --- a/homeassistant/components/fritzbox/model.py +++ b/homeassistant/components/fritzbox/model.py @@ -11,6 +11,7 @@ class EntityInfo(TypedDict): entity_id: str unit_of_measurement: str | None device_class: str | None + state_class: str | None class ClimateExtraAttributes(TypedDict, total=False): diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index ceae0bb757f..9d78afca4de 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -1,7 +1,13 @@ """Support for AVM FRITZ!SmartHome temperature sensor only devices.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from datetime import datetime + +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -9,11 +15,17 @@ from homeassistant.const import ( ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ENERGY_KILO_WATT_HOUR, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxEntity from .const import ( @@ -33,18 +45,15 @@ async def async_setup_entry( coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] for ain, device in coordinator.data.items(): - if ( - device.has_temperature_sensor - and not device.has_switch - and not device.has_thermostat - ): + if device.has_temperature_sensor and not device.has_thermostat: entities.append( FritzBoxTempSensor( { - ATTR_NAME: f"{device.name}", - ATTR_ENTITY_ID: f"{device.ain}", + ATTR_NAME: f"{device.name} Temperature", + ATTR_ENTITY_ID: f"{device.ain}_temperature", ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, coordinator, ain, @@ -59,6 +68,35 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}_battery", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_STATE_CLASS: None, + }, + coordinator, + ain, + ) + ) + + if device.has_powermeter: + entities.append( + FritzBoxPowerSensor( + { + ATTR_NAME: f"{device.name} Power Consumption", + ATTR_ENTITY_ID: f"{device.ain}_power_consumption", + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + }, + coordinator, + ain, + ) + ) + entities.append( + FritzBoxEnergySensor( + { + ATTR_NAME: f"{device.name} Total Energy", + ATTR_ENTITY_ID: f"{device.ain}_total_energy", + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, coordinator, ain, @@ -69,7 +107,7 @@ async def async_setup_entry( class FritzBoxBatterySensor(FritzBoxEntity, SensorEntity): - """The entity class for FRITZ!SmartHome sensors.""" + """The entity class for FRITZ!SmartHome battery sensors.""" @property def state(self) -> int | None: @@ -77,6 +115,34 @@ class FritzBoxBatterySensor(FritzBoxEntity, SensorEntity): return self.device.battery_level # type: ignore [no-any-return] +class FritzBoxPowerSensor(FritzBoxEntity, SensorEntity): + """The entity class for FRITZ!SmartHome power consumption sensors.""" + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if power := self.device.power: + return power / 1000 # type: ignore [no-any-return] + return 0.0 + + +class FritzBoxEnergySensor(FritzBoxEntity, SensorEntity): + """The entity class for FRITZ!SmartHome total energy sensors.""" + + @property + def state(self) -> float | None: + """Return the state of the sensor.""" + if energy := self.device.energy: + return energy / 1000 # type: ignore [no-any-return] + return 0.0 + + @property + def last_reset(self) -> datetime: + """Return the time when the sensor was last reset, if any.""" + # device does not provide timestamp of initialization + return utc_from_timestamp(0) + + class FritzBoxTempSensor(FritzBoxEntity, SensorEntity): """The entity class for FRITZ!SmartHome temperature sensors.""" diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index 82581473714..133db92feda 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -3,16 +3,14 @@ from __future__ import annotations from typing import Any +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_NAME, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, - ENERGY_KILO_WATT_HOUR, - TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,16 +19,11 @@ from . import FritzBoxEntity from .const import ( ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, - ATTR_TEMPERATURE_UNIT, - ATTR_TOTAL_CONSUMPTION, - ATTR_TOTAL_CONSUMPTION_UNIT, CONF_COORDINATOR, DOMAIN as FRITZBOX_DOMAIN, ) from .model import SwitchExtraAttributes -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -50,6 +43,7 @@ async def async_setup_entry( ATTR_ENTITY_ID: f"{device.ain}", ATTR_UNIT_OF_MEASUREMENT: None, ATTR_DEVICE_CLASS: None, + ATTR_STATE_CLASS: None, }, coordinator, ain, @@ -62,11 +56,6 @@ async def async_setup_entry( class FritzboxSwitch(FritzBoxEntity, SwitchEntity): """The switch class for FRITZ!SmartHome switches.""" - @property - def available(self) -> bool: - """Return if switch is available.""" - return self.device.present # type: ignore [no-any-return] - @property def is_on(self) -> bool: """Return true if the switch is on.""" @@ -89,22 +78,4 @@ class FritzboxSwitch(FritzBoxEntity, SwitchEntity): ATTR_STATE_DEVICE_LOCKED: self.device.device_lock, ATTR_STATE_LOCKED: self.device.lock, } - - if self.device.has_powermeter: - attrs[ - ATTR_TOTAL_CONSUMPTION - ] = f"{((self.device.energy or 0.0) / 1000):.3f}" - attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE - if self.device.has_temperature_sensor: - attrs[ATTR_TEMPERATURE] = str( - self.hass.config.units.temperature( - self.device.temperature, TEMP_CELSIUS - ) - ) - attrs[ATTR_TEMPERATURE_UNIT] = self.hass.config.units.temperature_unit return attrs - - @property - def current_power_w(self) -> float: - """Return the current power usage in W.""" - return self.device.power / 1000 # type: ignore [no-any-return] diff --git a/homeassistant/components/fritzbox/translations/de.json b/homeassistant/components/fritzbox/translations/de.json index ceaca6fd19a..7da8e616cfc 100644 --- a/homeassistant/components/fritzbox/translations/de.json +++ b/homeassistant/components/fritzbox/translations/de.json @@ -8,7 +8,7 @@ "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { - "invalid_auth": "Ung\u00fcltige Zugangsdaten" + "invalid_auth": "Ung\u00fcltige Authentifizierung" }, "flow_title": "AVM FRITZ!Box: {name}", "step": { diff --git a/homeassistant/components/fritzbox/translations/hu.json b/homeassistant/components/fritzbox/translations/hu.json index 630b15b990c..81639b1d830 100644 --- a/homeassistant/components/fritzbox/translations/hu.json +++ b/homeassistant/components/fritzbox/translations/hu.json @@ -22,7 +22,8 @@ "data": { "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } + }, + "description": "Friss\u00edtse a(z) {name} bejelentkez\u00e9si adatait." }, "user": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index 531fa13e232..0a1f7330c6d 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -3,7 +3,7 @@ "name": "AVM FRITZ!Box Call Monitor", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fritzbox_callmonitor", - "requirements": ["fritzconnection==1.4.2"], + "requirements": ["fritzconnection==1.6.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/fritzbox_callmonitor/translations/hu.json b/homeassistant/components/fritzbox_callmonitor/translations/hu.json index 3255d205fa1..5006dd77f14 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/hu.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "insufficient_permissions": "A felhaszn\u00e1l\u00f3nak nincs elegend\u0151 enged\u00e9lye az AVM FRITZ! Box be\u00e1ll\u00edt\u00e1sainak \u00e9s telefonk\u00f6nyveinek el\u00e9r\u00e9s\u00e9hez.", "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" }, "error": { @@ -23,5 +24,18 @@ } } } + }, + "options": { + "error": { + "malformed_prefixes": "Az el\u0151tagok hib\u00e1san vannak form\u00e1zva, ellen\u0151rizze a form\u00e1tumukat." + }, + "step": { + "init": { + "data": { + "prefixes": "El\u0151tagok (vessz\u0151vel elv\u00e1lasztott lista)" + }, + "title": "Konfigur\u00e1lja az el\u0151tagokat" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/fritzbox_netmonitor/__init__.py b/homeassistant/components/fritzbox_netmonitor/__init__.py deleted file mode 100644 index 8bea1da4a44..00000000000 --- a/homeassistant/components/fritzbox_netmonitor/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fritzbox_netmonitor component.""" diff --git a/homeassistant/components/fritzbox_netmonitor/manifest.json b/homeassistant/components/fritzbox_netmonitor/manifest.json deleted file mode 100644 index b52872fc044..00000000000 --- a/homeassistant/components/fritzbox_netmonitor/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "fritzbox_netmonitor", - "name": "AVM FRITZ!Box Net Monitor", - "documentation": "https://www.home-assistant.io/integrations/fritzbox_netmonitor", - "requirements": ["fritzconnection==1.4.2"], - "codeowners": [], - "iot_class": "local_polling" -} diff --git a/homeassistant/components/fritzbox_netmonitor/sensor.py b/homeassistant/components/fritzbox_netmonitor/sensor.py deleted file mode 100644 index 3c37de7664c..00000000000 --- a/homeassistant/components/fritzbox_netmonitor/sensor.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Support for monitoring an AVM Fritz!Box router.""" -from datetime import timedelta -import logging - -from fritzconnection.core.exceptions import FritzConnectionException -from fritzconnection.lib.fritzstatus import FritzStatus -from requests.exceptions import RequestException -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_HOST, CONF_NAME, STATE_UNAVAILABLE -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "fritz_netmonitor" -DEFAULT_HOST = "169.254.1.1" # This IP is valid for all FRITZ!Box routers. - -ATTR_BYTES_RECEIVED = "bytes_received" -ATTR_BYTES_SENT = "bytes_sent" -ATTR_TRANSMISSION_RATE_UP = "transmission_rate_up" -ATTR_TRANSMISSION_RATE_DOWN = "transmission_rate_down" -ATTR_EXTERNAL_IP = "external_ip" -ATTR_IS_CONNECTED = "is_connected" -ATTR_IS_LINKED = "is_linked" -ATTR_MAX_BYTE_RATE_DOWN = "max_byte_rate_down" -ATTR_MAX_BYTE_RATE_UP = "max_byte_rate_up" -ATTR_UPTIME = "uptime" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) - -STATE_ONLINE = "online" -STATE_OFFLINE = "offline" - -ICON = "mdi:web" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the FRITZ!Box monitor sensors.""" - name = config[CONF_NAME] - host = config[CONF_HOST] - - try: - fstatus = FritzStatus(address=host) - except (ValueError, TypeError, FritzConnectionException): - fstatus = None - - if fstatus is None: - _LOGGER.error("Failed to establish connection to FRITZ!Box: %s", host) - return 1 - _LOGGER.info("Successfully connected to FRITZ!Box") - - add_entities([FritzboxMonitorSensor(name, fstatus)], True) - - -class FritzboxMonitorSensor(SensorEntity): - """Implementation of a fritzbox monitor sensor.""" - - def __init__(self, name, fstatus): - """Initialize the sensor.""" - self._name = name - self._fstatus = fstatus - self._state = STATE_UNAVAILABLE - self._is_linked = self._is_connected = None - self._external_ip = self._uptime = None - self._bytes_sent = self._bytes_received = None - self._transmission_rate_up = None - self._transmission_rate_down = None - self._max_byte_rate_up = self._max_byte_rate_down = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name.rstrip() - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return ICON - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - # Don't return attributes if FritzBox is unreachable - if self._state == STATE_UNAVAILABLE: - return {} - return { - ATTR_IS_LINKED: self._is_linked, - ATTR_IS_CONNECTED: self._is_connected, - ATTR_EXTERNAL_IP: self._external_ip, - ATTR_UPTIME: self._uptime, - ATTR_BYTES_SENT: self._bytes_sent, - ATTR_BYTES_RECEIVED: self._bytes_received, - ATTR_TRANSMISSION_RATE_UP: self._transmission_rate_up, - ATTR_TRANSMISSION_RATE_DOWN: self._transmission_rate_down, - ATTR_MAX_BYTE_RATE_UP: self._max_byte_rate_up, - ATTR_MAX_BYTE_RATE_DOWN: self._max_byte_rate_down, - } - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Retrieve information from the FritzBox.""" - try: - self._is_linked = self._fstatus.is_linked - self._is_connected = self._fstatus.is_connected - self._external_ip = self._fstatus.external_ip - self._uptime = self._fstatus.uptime - self._bytes_sent = self._fstatus.bytes_sent - self._bytes_received = self._fstatus.bytes_received - transmission_rate = self._fstatus.transmission_rate - self._transmission_rate_up = transmission_rate[0] - self._transmission_rate_down = transmission_rate[1] - self._max_byte_rate_up = self._fstatus.max_byte_rate[0] - self._max_byte_rate_down = self._fstatus.max_byte_rate[1] - self._state = STATE_ONLINE if self._is_connected else STATE_OFFLINE - except RequestException as err: - self._state = STATE_UNAVAILABLE - _LOGGER.warning("Could not reach FRITZ!Box: %s", err) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index d526fc90b32..1ae95d30fd5 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -2,7 +2,7 @@ "domain": "fronius", "name": "Fronius", "documentation": "https://www.home-assistant.io/integrations/fronius", - "requirements": ["pyfronius==0.5.2"], + "requirements": ["pyfronius==0.5.3"], "codeowners": ["@nielstron"], "iot_class": "local_polling" } diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index ac006638912..6f949334d02 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -8,17 +8,26 @@ import logging from pyfronius import Fronius import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_DEVICE, CONF_MONITORED_CONDITIONS, CONF_RESOURCE, CONF_SCAN_INTERVAL, CONF_SENSOR_TYPE, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, ) +from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) @@ -152,6 +161,12 @@ class FroniusAdapter: """Whether the fronius device is active.""" return self._available + def entity_description( # pylint: disable=no-self-use + self, key + ) -> SensorEntityDescription | None: + """Create entity description for a key.""" + return None + async def async_update(self): """Retrieve and update latest state.""" try: @@ -198,14 +213,28 @@ class FroniusAdapter: async def _update(self) -> dict: """Return values of interest.""" - async def register(self, sensor): + @callback + def register(self, sensor): """Register child sensor for update subscriptions.""" self._registered_sensors.add(sensor) + return lambda: self._registered_sensors.remove(sensor) class FroniusInverterSystem(FroniusAdapter): """Adapter for the fronius inverter with system scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if key != "energy_total": + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_inverter_data() @@ -214,6 +243,18 @@ class FroniusInverterSystem(FroniusAdapter): class FroniusInverterDevice(FroniusAdapter): """Adapter for the fronius inverter with device scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if key != "energy_total": + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_inverter_data(self._device) @@ -230,6 +271,18 @@ class FroniusStorage(FroniusAdapter): class FroniusMeterSystem(FroniusAdapter): """Adapter for the fronius meter with system scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if not key.startswith("energy_real_"): + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_system_meter_data() @@ -238,6 +291,18 @@ class FroniusMeterSystem(FroniusAdapter): class FroniusMeterDevice(FroniusAdapter): """Adapter for the fronius meter with device scope.""" + def entity_description(self, key): + """Return the entity descriptor.""" + if not key.startswith("energy_real_"): + return None + + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_meter_data(self._device) @@ -246,6 +311,14 @@ class FroniusMeterDevice(FroniusAdapter): class FroniusPowerFlow(FroniusAdapter): """Adapter for the fronius power flow.""" + def entity_description(self, key): + """Return the entity descriptor.""" + return SensorEntityDescription( + key=key, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ) + async def _update(self): """Get the values for the current state.""" return await self.bridge.current_power_flow() @@ -254,27 +327,13 @@ class FroniusPowerFlow(FroniusAdapter): class FroniusTemplateSensor(SensorEntity): """Sensor for the single values (e.g. pv power, ac power).""" - def __init__(self, parent: FroniusAdapter, name): + def __init__(self, parent: FroniusAdapter, key): """Initialize a singular value sensor.""" - self._name = name - self.parent = parent - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" - - @property - def state(self): - """Return the current state.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit + self._key = key + self._attr_name = f"{key.replace('_', ' ').capitalize()} {parent.name}" + self._parent = parent + if entity_description := parent.entity_description(key): + self.entity_description = entity_description @property def should_poll(self): @@ -284,19 +343,19 @@ class FroniusTemplateSensor(SensorEntity): @property def available(self): """Whether the fronius device is active.""" - return self.parent.available + return self._parent.available async def async_update(self): """Update the internal state.""" - state = self.parent.data.get(self._name) - self._state = state.get("value") - if isinstance(self._state, float): - self._state = round(self._state, 2) - self._unit = state.get("unit") + state = self._parent.data.get(self._key) + self._attr_state = state.get("value") + if isinstance(self._attr_state, float): + self._attr_state = round(self._attr_state, 2) + self._attr_unit_of_measurement = state.get("unit") async def async_added_to_hass(self): """Register at parent component for updates.""" - await self.parent.register(self) + self.async_on_remove(self._parent.register(self)) def __hash__(self): """Hash sensor by hashing its name.""" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 392806dc885..8b92745f4d4 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -569,7 +569,9 @@ class IndexView(web_urldispatcher.AbstractResource): """Get template.""" tpl = self._template_cache if tpl is None: - with open(str(_frontend_root(self.repo_path) / "index.html")) as file: + with (_frontend_root(self.repo_path) / "index.html").open( + encoding="utf8" + ) as file: tpl = jinja2.Template(file.read()) # Cache template if not running from repository diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7af6e2bc733..fbf676687cb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20210707.0" + "home-assistant-frontend==20210803.2" ], "dependencies": [ "api", diff --git a/homeassistant/components/garages_amsterdam/translations/de.json b/homeassistant/components/garages_amsterdam/translations/de.json index aa13e22eabb..8a35e2f2e26 100644 --- a/homeassistant/components/garages_amsterdam/translations/de.json +++ b/homeassistant/components/garages_amsterdam/translations/de.json @@ -10,7 +10,7 @@ "data": { "garage_name": "Name der Garage" }, - "title": "W\u00e4hlen Sie eine Garage zur \u00dcberwachung aus" + "title": "W\u00e4hle eine Garage zur \u00dcberwachung aus" } } }, diff --git a/homeassistant/components/garages_amsterdam/translations/fr.json b/homeassistant/components/garages_amsterdam/translations/fr.json new file mode 100644 index 00000000000..68530899d1e --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "garage_name": "Nom du garage" + }, + "title": "Choisisser un garage \u00e0 surveiller" + } + } + }, + "title": "Garages Amsterdam" +} \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/hu.json b/homeassistant/components/garages_amsterdam/translations/hu.json index c02cd4077ba..66bc29c02fa 100644 --- a/homeassistant/components/garages_amsterdam/translations/hu.json +++ b/homeassistant/components/garages_amsterdam/translations/hu.json @@ -4,6 +4,15 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "garage_name": "Gar\u00e1zs neve" + }, + "title": "V\u00e1lasszon egy gar\u00e1zst a megfigyel\u00e9shez" + } } - } + }, + "title": "Gar\u00e1zsok Amszterdam" } \ No newline at end of file diff --git a/homeassistant/components/garages_amsterdam/translations/id.json b/homeassistant/components/garages_amsterdam/translations/id.json new file mode 100644 index 00000000000..37a312250a1 --- /dev/null +++ b/homeassistant/components/garages_amsterdam/translations/id.json @@ -0,0 +1,9 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/__init__.py b/homeassistant/components/garmin_connect/__init__.py deleted file mode 100644 index 180fcdb08a2..00000000000 --- a/homeassistant/components/garmin_connect/__init__.py +++ /dev/null @@ -1,107 +0,0 @@ -"""The Garmin Connect integration.""" -from datetime import date -import logging - -from garminconnect_ha import ( - Garmin, - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.util import Throttle - -from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = ["sensor"] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Garmin Connect from a config entry.""" - - username: str = entry.data[CONF_USERNAME] - password: str = entry.data[CONF_PASSWORD] - - api = Garmin(username, password) - - try: - await hass.async_add_executor_job(api.login) - except ( - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - ) as err: - _LOGGER.error("Error occurred during Garmin Connect login request: %s", err) - return False - except (GarminConnectConnectionError) as err: - _LOGGER.error( - "Connection error occurred during Garmin Connect login request: %s", err - ) - raise ConfigEntryNotReady from err - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error occurred during Garmin Connect login request") - return False - - garmin_data = GarminConnectData(hass, api) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = garmin_data - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): - """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 GarminConnectData: - """Define an object to hold sensor data.""" - - def __init__(self, hass, client): - """Initialize.""" - self.hass = hass - self.client = client - self.data = None - - @Throttle(DEFAULT_UPDATE_INTERVAL) - async def async_update(self): - """Update data via API wrapper.""" - today = date.today() - - try: - summary = await self.hass.async_add_executor_job( - self.client.get_user_summary, today.isoformat() - ) - body = await self.hass.async_add_executor_job( - self.client.get_body_composition, today.isoformat() - ) - - self.data = { - **summary, - **body["totalAverage"], - } - self.data["nextAlarm"] = await self.hass.async_add_executor_job( - self.client.get_device_alarms - ) - except ( - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - GarminConnectConnectionError, - ) as err: - _LOGGER.error( - "Error occurred during Garmin Connect update requests: %s", err - ) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Unknown error occurred during Garmin Connect update requests" - ) diff --git a/homeassistant/components/garmin_connect/alarm_util.py b/homeassistant/components/garmin_connect/alarm_util.py deleted file mode 100644 index 4964d70e886..00000000000 --- a/homeassistant/components/garmin_connect/alarm_util.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Utility method for converting Garmin Connect alarms to python datetime.""" -from datetime import date, datetime, timedelta -import logging - -_LOGGER = logging.getLogger(__name__) - -DAY_TO_NUMBER = { - "Mo": 1, - "M": 1, - "Tu": 2, - "We": 3, - "W": 3, - "Th": 4, - "Fr": 5, - "F": 5, - "Sa": 6, - "Su": 7, -} - - -def calculate_next_active_alarms(alarms): - """ - Calculate garmin next active alarms from settings. - - Alarms are sorted by time - """ - active_alarms = [] - _LOGGER.debug(alarms) - - for alarm_setting in alarms: - if alarm_setting["alarmMode"] != "ON": - continue - for day in alarm_setting["alarmDays"]: - alarm_time = alarm_setting["alarmTime"] - if day == "ONCE": - midnight = datetime.combine(date.today(), datetime.min.time()) - alarm = midnight + timedelta(minutes=alarm_time) - if alarm < datetime.now(): - alarm += timedelta(days=1) - else: - start_of_week = datetime.combine( - date.today() - timedelta(days=datetime.today().isoweekday() % 7), - datetime.min.time(), - ) - days_to_add = DAY_TO_NUMBER[day] % 7 - alarm = start_of_week + timedelta(minutes=alarm_time, days=days_to_add) - if alarm < datetime.now(): - alarm += timedelta(days=7) - active_alarms.append(alarm.isoformat()) - return sorted(active_alarms) if active_alarms else None diff --git a/homeassistant/components/garmin_connect/config_flow.py b/homeassistant/components/garmin_connect/config_flow.py deleted file mode 100644 index e9966859f99..00000000000 --- a/homeassistant/components/garmin_connect/config_flow.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Config flow for Garmin Connect integration.""" -import logging - -from garminconnect_ha import ( - Garmin, - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - - -class GarminConnectConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Garmin Connect.""" - - VERSION = 1 - - async def _show_setup_form(self, errors=None): - """Show the setup form to the user.""" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} - ), - errors=errors or {}, - ) - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - if user_input is None: - return await self._show_setup_form() - - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - api = Garmin(username, password) - - errors = {} - try: - await self.hass.async_add_executor_job(api.login) - except GarminConnectConnectionError: - errors["base"] = "cannot_connect" - return await self._show_setup_form(errors) - except GarminConnectAuthenticationError: - errors["base"] = "invalid_auth" - return await self._show_setup_form(errors) - except GarminConnectTooManyRequestsError: - errors["base"] = "too_many_requests" - return await self._show_setup_form(errors) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - return await self._show_setup_form(errors) - - await self.async_set_unique_id(username) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=username, - data={ - CONF_ID: username, - CONF_USERNAME: username, - CONF_PASSWORD: password, - }, - ) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py deleted file mode 100644 index 19ed4ca4d94..00000000000 --- a/homeassistant/components/garmin_connect/const.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Constants for the Garmin Connect integration.""" -from datetime import timedelta - -from homeassistant.const import ( - DEVICE_CLASS_TIMESTAMP, - LENGTH_METERS, - MASS_KILOGRAMS, - PERCENTAGE, - TIME_MINUTES, -) - -DOMAIN = "garmin_connect" -ATTRIBUTION = "connect.garmin.com" -DEFAULT_UPDATE_INTERVAL = timedelta(minutes=10) - -GARMIN_ENTITY_LIST = { - "totalSteps": ["Total Steps", "steps", "mdi:walk", None, True], - "dailyStepGoal": ["Daily Step Goal", "steps", "mdi:walk", None, True], - "totalKilocalories": ["Total KiloCalories", "kcal", "mdi:food", None, True], - "activeKilocalories": ["Active KiloCalories", "kcal", "mdi:food", None, True], - "bmrKilocalories": ["BMR KiloCalories", "kcal", "mdi:food", None, True], - "consumedKilocalories": ["Consumed KiloCalories", "kcal", "mdi:food", None, False], - "burnedKilocalories": ["Burned KiloCalories", "kcal", "mdi:food", None, True], - "remainingKilocalories": [ - "Remaining KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "netRemainingKilocalories": [ - "Net Remaining KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "netCalorieGoal": ["Net Calorie Goal", "cal", "mdi:food", None, False], - "totalDistanceMeters": [ - "Total Distance Mtr", - LENGTH_METERS, - "mdi:walk", - None, - True, - ], - "wellnessStartTimeLocal": [ - "Wellness Start Time", - None, - "mdi:clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "wellnessEndTimeLocal": [ - "Wellness End Time", - None, - "mdi:clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "wellnessDescription": ["Wellness Description", "", "mdi:clock", None, False], - "wellnessDistanceMeters": [ - "Wellness Distance Mtr", - LENGTH_METERS, - "mdi:walk", - None, - False, - ], - "wellnessActiveKilocalories": [ - "Wellness Active KiloCalories", - "kcal", - "mdi:food", - None, - False, - ], - "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], - "highlyActiveSeconds": [ - "Highly Active Time", - TIME_MINUTES, - "mdi:fire", - None, - False, - ], - "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True], - "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True], - "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True], - "measurableAwakeDuration": [ - "Awake Duration", - TIME_MINUTES, - "mdi:sleep", - None, - True, - ], - "measurableAsleepDuration": [ - "Sleep Duration", - TIME_MINUTES, - "mdi:sleep", - None, - True, - ], - "floorsAscendedInMeters": [ - "Floors Ascended Mtr", - LENGTH_METERS, - "mdi:stairs", - None, - False, - ], - "floorsDescendedInMeters": [ - "Floors Descended Mtr", - LENGTH_METERS, - "mdi:stairs", - None, - False, - ], - "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], - "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], - "userFloorsAscendedGoal": [ - "Floors Ascended Goal", - "floors", - "mdi:stairs", - None, - True, - ], - "minHeartRate": ["Min Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "maxHeartRate": ["Max Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "restingHeartRate": ["Resting Heart Rate", "bpm", "mdi:heart-pulse", None, True], - "minAvgHeartRate": ["Min Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], - "maxAvgHeartRate": ["Max Avg Heart Rate", "bpm", "mdi:heart-pulse", None, False], - "abnormalHeartRateAlertsCount": [ - "Abnormal HR Counts", - "", - "mdi:heart-pulse", - None, - False, - ], - "lastSevenDaysAvgRestingHeartRate": [ - "Last 7 Days Avg Heart Rate", - "bpm", - "mdi:heart-pulse", - None, - False, - ], - "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], - "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], - "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], - "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False], - "restStressDuration": [ - "Rest Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "activityStressDuration": [ - "Activity Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "uncategorizedStressDuration": [ - "Uncat. Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "totalStressDuration": [ - "Total Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "lowStressDuration": [ - "Low Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "mediumStressDuration": [ - "Medium Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "highStressDuration": [ - "High Stress Duration", - TIME_MINUTES, - "mdi:flash-alert", - None, - True, - ], - "stressPercentage": [ - "Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "restStressPercentage": [ - "Rest Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "activityStressPercentage": [ - "Activity Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "uncategorizedStressPercentage": [ - "Uncat. Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "lowStressPercentage": [ - "Low Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "mediumStressPercentage": [ - "Medium Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "highStressPercentage": [ - "High Stress Percentage", - PERCENTAGE, - "mdi:flash-alert", - None, - False, - ], - "moderateIntensityMinutes": [ - "Moderate Intensity", - TIME_MINUTES, - "mdi:flash-alert", - None, - False, - ], - "vigorousIntensityMinutes": [ - "Vigorous Intensity", - TIME_MINUTES, - "mdi:run-fast", - None, - False, - ], - "intensityMinutesGoal": [ - "Intensity Goal", - TIME_MINUTES, - "mdi:run-fast", - None, - False, - ], - "bodyBatteryChargedValue": [ - "Body Battery Charged", - PERCENTAGE, - "mdi:battery-charging-100", - None, - True, - ], - "bodyBatteryDrainedValue": [ - "Body Battery Drained", - PERCENTAGE, - "mdi:battery-alert-variant-outline", - None, - True, - ], - "bodyBatteryHighestValue": [ - "Body Battery Highest", - PERCENTAGE, - "mdi:battery-heart", - None, - True, - ], - "bodyBatteryLowestValue": [ - "Body Battery Lowest", - PERCENTAGE, - "mdi:battery-heart-outline", - None, - True, - ], - "bodyBatteryMostRecentValue": [ - "Body Battery Most Recent", - PERCENTAGE, - "mdi:battery-positive", - None, - True, - ], - "averageSpo2": ["Average SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "lowestSpo2": ["Lowest SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "latestSpo2": ["Latest SPO2", PERCENTAGE, "mdi:diabetes", None, True], - "latestSpo2ReadingTimeLocal": [ - "Latest SPO2 Time", - None, - "mdi:diabetes", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "averageMonitoringEnvironmentAltitude": [ - "Average Altitude", - PERCENTAGE, - "mdi:image-filter-hdr", - None, - False, - ], - "highestRespirationValue": [ - "Highest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "lowestRespirationValue": [ - "Lowest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "latestRespirationValue": [ - "Latest Respiration", - "brpm", - "mdi:progress-clock", - None, - False, - ], - "latestRespirationTimeGMT": [ - "Latest Respiration Update", - None, - "mdi:progress-clock", - DEVICE_CLASS_TIMESTAMP, - False, - ], - "weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", None, False], - "bmi": ["BMI", "", "mdi:food", None, False], - "bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, False], - "bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, False], - "bodyMass": ["Body Mass", MASS_KILOGRAMS, "mdi:food", None, False], - "muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", None, False], - "physiqueRating": ["Physique Rating", "", "mdi:numeric", None, False], - "visceralFat": ["Visceral Fat", "", "mdi:food", None, False], - "metabolicAge": ["Metabolic Age", "", "mdi:calendar-heart", None, False], - "nextAlarm": ["Next Alarm Time", None, "mdi:alarm", DEVICE_CLASS_TIMESTAMP, True], -} diff --git a/homeassistant/components/garmin_connect/manifest.json b/homeassistant/components/garmin_connect/manifest.json deleted file mode 100644 index 43b4a028290..00000000000 --- a/homeassistant/components/garmin_connect/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "garmin_connect", - "name": "Garmin Connect", - "documentation": "https://www.home-assistant.io/integrations/garmin_connect", - "requirements": ["garminconnect_ha==0.1.6"], - "codeowners": ["@cyberjunky"], - "config_flow": true, - "iot_class": "cloud_polling" -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/sensor.py b/homeassistant/components/garmin_connect/sensor.py deleted file mode 100644 index 96f352c75b4..00000000000 --- a/homeassistant/components/garmin_connect/sensor.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Platform for Garmin Connect integration.""" -from __future__ import annotations - -import logging - -from garminconnect_ha import ( - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) - -from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo - -from .alarm_util import calculate_next_active_alarms -from .const import ATTRIBUTION, DOMAIN, GARMIN_ENTITY_LIST - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities -) -> None: - """Set up Garmin Connect sensor based on a config entry.""" - garmin_data = hass.data[DOMAIN][entry.entry_id] - unique_id = entry.data[CONF_ID] - - try: - await garmin_data.async_update() - except ( - GarminConnectConnectionError, - GarminConnectAuthenticationError, - GarminConnectTooManyRequestsError, - ) as err: - _LOGGER.error("Error occurred during Garmin Connect Client update: %s", err) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unknown error occurred during Garmin Connect Client update") - - entities = [] - for ( - sensor_type, - (name, unit, icon, device_class, enabled_by_default), - ) in GARMIN_ENTITY_LIST.items(): - - _LOGGER.debug( - "Registering entity: %s, %s, %s, %s, %s, %s", - sensor_type, - name, - unit, - icon, - device_class, - enabled_by_default, - ) - entities.append( - GarminConnectSensor( - garmin_data, - unique_id, - sensor_type, - name, - unit, - icon, - device_class, - enabled_by_default, - ) - ) - - async_add_entities(entities, True) - - -class GarminConnectSensor(SensorEntity): - """Representation of a Garmin Connect Sensor.""" - - def __init__( - self, - data, - unique_id, - sensor_type, - name, - unit, - icon, - device_class, - enabled_default: bool = True, - ): - """Initialize.""" - self._data = data - self._unique_id = unique_id - self._type = sensor_type - self._name = name - self._unit = unit - self._icon = icon - self._device_class = device_class - self._enabled_default = enabled_default - self._available = True - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return self._icon - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unique_id(self) -> str: - """Return the unique ID for this sensor.""" - return f"{self._unique_id}_{self._type}" - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def extra_state_attributes(self): - """Return attributes for sensor.""" - if not self._data.data: - return {} - attributes = { - "source": self._data.data["source"], - "last_synced": self._data.data["lastSyncTimestampGMT"], - ATTR_ATTRIBUTION: ATTRIBUTION, - } - if self._type == "nextAlarm": - attributes["next_alarms"] = calculate_next_active_alarms( - self._data.data[self._type] - ) - return attributes - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return { - "identifiers": {(DOMAIN, self._unique_id)}, - "name": "Garmin Connect", - "manufacturer": "Garmin Connect", - } - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - async def async_update(self): - """Update the data from Garmin Connect.""" - if not self.enabled: - return - - await self._data.async_update() - data = self._data.data - if not data: - _LOGGER.error("Didn't receive data from Garmin Connect") - return - if data.get(self._type) is None: - _LOGGER.debug("Entity type %s not set in fetched data", self._type) - self._available = False - return - self._available = True - - if "Duration" in self._type or "Seconds" in self._type: - self._state = data[self._type] // 60 - elif "Mass" in self._type or self._type == "weight": - self._state = round((data[self._type] / 1000), 2) - elif ( - self._type == "bodyFat" or self._type == "bodyWater" or self._type == "bmi" - ): - self._state = round(data[self._type], 2) - elif self._type == "nextAlarm": - active_alarms = calculate_next_active_alarms(data[self._type]) - if active_alarms: - self._state = active_alarms[0] - else: - self._available = False - else: - self._state = data[self._type] - - _LOGGER.debug( - "Entity %s set to state %s %s", self._type, self._state, self._unit - ) diff --git a/homeassistant/components/garmin_connect/translations/ca.json b/homeassistant/components/garmin_connect/translations/ca.json deleted file mode 100644 index 73b12090fcf..00000000000 --- a/homeassistant/components/garmin_connect/translations/ca.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "[%key::common::config_flow::abort::already_configured_account%]" - }, - "error": { - "cannot_connect": "Ha fallat la connexi\u00f3", - "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", - "too_many_requests": "Massa sol\u00b7licituds, torna-ho a intentar m\u00e9s tard.", - "unknown": "Error inesperat" - }, - "step": { - "user": { - "data": { - "password": "Contrasenya", - "username": "Nom d'usuari" - }, - "description": "Introdueix les teves credencials.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/da.json b/homeassistant/components/garmin_connect/translations/da.json deleted file mode 100644 index f664ad0e1f4..00000000000 --- a/homeassistant/components/garmin_connect/translations/da.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Denne konto er allerede konfigureret." - }, - "error": { - "cannot_connect": "Kunne ikke oprette forbindelse - pr\u00f8v igen.", - "invalid_auth": "Ugyldig godkendelse.", - "too_many_requests": "For mange anmodninger - pr\u00f8v igen senere.", - "unknown": "Uventet fejl." - }, - "step": { - "user": { - "data": { - "password": "Adgangskode", - "username": "Brugernavn" - }, - "description": "Indtast dine legitimationsoplysninger.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/de.json b/homeassistant/components/garmin_connect/translations/de.json deleted file mode 100644 index 9186f753a77..00000000000 --- a/homeassistant/components/garmin_connect/translations/de.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Dieses Konto ist bereits konfiguriert." - }, - "error": { - "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung", - "too_many_requests": "Zu viele Anfragen, versuche es sp\u00e4ter erneut.", - "unknown": "Unerwarteter Fehler" - }, - "step": { - "user": { - "data": { - "password": "Passwort", - "username": "Benutzername" - }, - "description": "Geben Sie Ihre Zugangsdaten ein.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/en.json b/homeassistant/components/garmin_connect/translations/en.json deleted file mode 100644 index c1b563d38f3..00000000000 --- a/homeassistant/components/garmin_connect/translations/en.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "too_many_requests": "Too many requests, retry later.", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "Enter your credentials.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es-419.json b/homeassistant/components/garmin_connect/translations/es-419.json deleted file mode 100644 index 42263ce0780..00000000000 --- a/homeassistant/components/garmin_connect/translations/es-419.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Esta cuenta ya est\u00e1 configurada." - }, - "error": { - "cannot_connect": "No se pudo conectar, intente nuevamente.", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", - "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", - "unknown": "Error inesperado." - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Nombre de usuario" - }, - "description": "Ingrese sus credenciales.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/es.json b/homeassistant/components/garmin_connect/translations/es.json deleted file mode 100644 index bef92af2948..00000000000 --- a/homeassistant/components/garmin_connect/translations/es.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "La cuenta ya ha sido configurada" - }, - "error": { - "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", - "too_many_requests": "Demasiadas solicitudes, vuelva a intentarlo m\u00e1s tarde.", - "unknown": "Error inesperado" - }, - "step": { - "user": { - "data": { - "password": "Contrase\u00f1a", - "username": "Usuario" - }, - "description": "Introduzca sus credenciales.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/et.json b/homeassistant/components/garmin_connect/translations/et.json deleted file mode 100644 index eeaefe92700..00000000000 --- a/homeassistant/components/garmin_connect/translations/et.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto on juba seadistatud" - }, - "error": { - "cannot_connect": "\u00dchendamine nurjus", - "invalid_auth": "Tuvastamine nurjus", - "too_many_requests": "Liiga palju taotlusi, proovi hiljem uuesti.", - "unknown": "Tundmatu viga" - }, - "step": { - "user": { - "data": { - "password": "Salas\u00f5na", - "username": "Kasutajanimi" - }, - "description": "Sisesta oma mandaat.", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/fr.json b/homeassistant/components/garmin_connect/translations/fr.json deleted file mode 100644 index ce97ccccf1b..00000000000 --- a/homeassistant/components/garmin_connect/translations/fr.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ce compte est d\u00e9j\u00e0 configur\u00e9." - }, - "error": { - "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer.", - "invalid_auth": "Authentification non valide.", - "too_many_requests": "Trop de demandes, r\u00e9essayez plus tard.", - "unknown": "Erreur inattendue." - }, - "step": { - "user": { - "data": { - "password": "Mot de passe", - "username": "Nom d'utilisateur" - }, - "description": "Entrez vos informations d'identification.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/hu.json b/homeassistant/components/garmin_connect/translations/hu.json deleted file mode 100644 index ae518acf001..00000000000 --- a/homeassistant/components/garmin_connect/translations/hu.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" - }, - "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", - "too_many_requests": "T\u00fal sok k\u00e9r\u00e9s, pr\u00f3b\u00e1lkozzon k\u00e9s\u0151bb \u00fajra.", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" - }, - "step": { - "user": { - "data": { - "password": "Jelsz\u00f3", - "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - }, - "description": "Adja meg a hiteles\u00edt\u0151 adatait.", - "title": "Garmin Csatlakoz\u00e1s" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/id.json b/homeassistant/components/garmin_connect/translations/id.json deleted file mode 100644 index 27460757234..00000000000 --- a/homeassistant/components/garmin_connect/translations/id.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Akun sudah dikonfigurasi" - }, - "error": { - "cannot_connect": "Gagal terhubung", - "invalid_auth": "Autentikasi tidak valid", - "too_many_requests": "Terlalu banyak permintaan, coba lagi nanti.", - "unknown": "Kesalahan yang tidak diharapkan" - }, - "step": { - "user": { - "data": { - "password": "Kata Sandi", - "username": "Nama Pengguna" - }, - "description": "Masukkan kredensial Anda.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/it.json b/homeassistant/components/garmin_connect/translations/it.json deleted file mode 100644 index 791de295a80..00000000000 --- a/homeassistant/components/garmin_connect/translations/it.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'account \u00e8 gi\u00e0 configurato" - }, - "error": { - "cannot_connect": "Impossibile connettersi", - "invalid_auth": "Autenticazione non valida", - "too_many_requests": "Troppe richieste, riprovare pi\u00f9 tardi.", - "unknown": "Errore imprevisto" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Nome utente" - }, - "description": "Inserisci le tue credenziali", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ko.json b/homeassistant/components/garmin_connect/translations/ko.json deleted file mode 100644 index 4d5330a824f..00000000000 --- a/homeassistant/components/garmin_connect/translations/ko.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "error": { - "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", - "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "too_many_requests": "\uc694\uccad\uc774 \ub108\ubb34 \ub9ce\uc2b5\ub2c8\ub2e4. \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", - "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" - }, - "step": { - "user": { - "data": { - "password": "\ube44\ubc00\ubc88\ud638", - "username": "\uc0ac\uc6a9\uc790 \uc774\ub984" - }, - "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/lb.json b/homeassistant/components/garmin_connect/translations/lb.json deleted file mode 100644 index 583942b1575..00000000000 --- a/homeassistant/components/garmin_connect/translations/lb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kont ass scho konfigur\u00e9iert" - }, - "error": { - "cannot_connect": "Feeler beim verbannen", - "invalid_auth": "Ong\u00eblteg Authentifikatioun", - "too_many_requests": "Ze vill Ufroen, prob\u00e9iert sp\u00e9ider nach emol.", - "unknown": "Onerwaarte Feeler" - }, - "step": { - "user": { - "data": { - "password": "Passwuert", - "username": "Benotzernumm" - }, - "description": "F\u00ebllt \u00e4r Umeldungs Informatiounen aus.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/nl.json b/homeassistant/components/garmin_connect/translations/nl.json deleted file mode 100644 index e751aaf1b5c..00000000000 --- a/homeassistant/components/garmin_connect/translations/nl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Account is al geconfigureerd" - }, - "error": { - "cannot_connect": "Kan geen verbinding maken", - "invalid_auth": "Ongeldige authenticatie", - "too_many_requests": "Te veel aanvragen, probeer het later opnieuw.", - "unknown": "Onverwachte fout" - }, - "step": { - "user": { - "data": { - "password": "Wachtwoord", - "username": "Gebruikersnaam" - }, - "description": "Voer uw gegevens in", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/no.json b/homeassistant/components/garmin_connect/translations/no.json deleted file mode 100644 index 41cc222bb73..00000000000 --- a/homeassistant/components/garmin_connect/translations/no.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Kontoen er allerede konfigurert" - }, - "error": { - "cannot_connect": "Tilkobling mislyktes", - "invalid_auth": "Ugyldig godkjenning", - "too_many_requests": "For mange foresp\u00f8rsler, pr\u00f8v p\u00e5 nytt senere.", - "unknown": "Uventet feil" - }, - "step": { - "user": { - "data": { - "password": "Passord", - "username": "Brukernavn" - }, - "description": "Fyll inn legitimasjonen din.", - "title": "" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pl.json b/homeassistant/components/garmin_connect/translations/pl.json deleted file mode 100644 index 715258e15f9..00000000000 --- a/homeassistant/components/garmin_connect/translations/pl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Konto jest ju\u017c skonfigurowane" - }, - "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "invalid_auth": "Niepoprawne uwierzytelnienie", - "too_many_requests": "Zbyt wiele \u017c\u0105da\u0144, spr\u00f3buj ponownie p\u00f3\u017aniej", - "unknown": "Nieoczekiwany b\u0142\u0105d" - }, - "step": { - "user": { - "data": { - "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika" - }, - "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pt-BR.json b/homeassistant/components/garmin_connect/translations/pt-BR.json deleted file mode 100644 index 157ac3f0477..00000000000 --- a/homeassistant/components/garmin_connect/translations/pt-BR.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "config": { - "step": { - "user": { - "description": "Digite suas credenciais.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/pt.json b/homeassistant/components/garmin_connect/translations/pt.json deleted file mode 100644 index 2d9b2f9e9c5..00000000000 --- a/homeassistant/components/garmin_connect/translations/pt.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Conta j\u00e1 configurada" - }, - "error": { - "cannot_connect": "Falha na liga\u00e7\u00e3o", - "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", - "unknown": "Erro inesperado" - }, - "step": { - "user": { - "data": { - "password": "Palavra-passe", - "username": "Nome de Utilizador" - }, - "description": "Introduza as suas credenciais.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/ru.json b/homeassistant/components/garmin_connect/translations/ru.json deleted file mode 100644 index 066c337309f..00000000000 --- a/homeassistant/components/garmin_connect/translations/ru.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." - }, - "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", - "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", - "too_many_requests": "\u0421\u043b\u0438\u0448\u043a\u043e\u043c \u043c\u043d\u043e\u0433\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435.", - "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" - }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sl.json b/homeassistant/components/garmin_connect/translations/sl.json deleted file mode 100644 index 594cbffeaa7..00000000000 --- a/homeassistant/components/garmin_connect/translations/sl.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ta ra\u010dun je \u017ee konfiguriran." - }, - "error": { - "cannot_connect": "Povezava ni uspela, poskusite znova.", - "invalid_auth": "Neveljavna avtentikacija.", - "too_many_requests": "Preve\u010d zahtev, poskusite pozneje.", - "unknown": "Nepri\u010dakovana napaka." - }, - "step": { - "user": { - "data": { - "password": "Geslo", - "username": "Uporabni\u0161ko ime" - }, - "description": "Vnesite svoje poverilnice.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/sv.json b/homeassistant/components/garmin_connect/translations/sv.json deleted file mode 100644 index 0f11ab2a8b9..00000000000 --- a/homeassistant/components/garmin_connect/translations/sv.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Det h\u00e4r kontot har redan konfigurerats." - }, - "error": { - "cannot_connect": "Kunde inte ansluta, var god f\u00f6rs\u00f6k igen.", - "invalid_auth": "Ogiltig autentisering.", - "too_many_requests": "F\u00f6r m\u00e5nga f\u00f6rfr\u00e5gningar, f\u00f6rs\u00f6k igen senare.", - "unknown": "Ov\u00e4ntat fel." - }, - "step": { - "user": { - "data": { - "password": "L\u00f6senord", - "username": "Anv\u00e4ndarnamn" - }, - "description": "Ange dina anv\u00e4ndaruppgifter.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/tr.json b/homeassistant/components/garmin_connect/translations/tr.json deleted file mode 100644 index a83e1936fb4..00000000000 --- a/homeassistant/components/garmin_connect/translations/tr.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" - }, - "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131", - "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", - "unknown": "Beklenmeyen hata" - }, - "step": { - "user": { - "data": { - "password": "Parola", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/uk.json b/homeassistant/components/garmin_connect/translations/uk.json deleted file mode 100644 index aef0632b0f1..00000000000 --- a/homeassistant/components/garmin_connect/translations/uk.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0426\u0435\u0439 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant." - }, - "error": { - "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", - "invalid_auth": "\u041d\u0435\u0432\u0456\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f.", - "too_many_requests": "\u0417\u0430\u043d\u0430\u0434\u0442\u043e \u0431\u0430\u0433\u0430\u0442\u043e \u0437\u0430\u043f\u0438\u0442\u0456\u0432, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437 \u043f\u0456\u0437\u043d\u0456\u0448\u0435.", - "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" - }, - "step": { - "user": { - "data": { - "password": "\u041f\u0430\u0440\u043e\u043b\u044c", - "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" - }, - "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0412\u0430\u0448\u0456 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0456 \u0434\u0430\u043d\u0456.", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/zh-Hant.json b/homeassistant/components/garmin_connect/translations/zh-Hant.json deleted file mode 100644 index cbf928152aa..00000000000 --- a/homeassistant/components/garmin_connect/translations/zh-Hant.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", - "too_many_requests": "\u8acb\u6c42\u6b21\u6578\u904e\u591a\uff0c\u8acb\u7a0d\u5f8c\u91cd\u8a66\u3002", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" - }, - "step": { - "user": { - "data": { - "password": "\u5bc6\u78bc", - "username": "\u4f7f\u7528\u8005\u540d\u7a31" - }, - "description": "\u8f38\u5165\u6191\u8b49\u3002", - "title": "Garmin Connect" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 8ab7bec48ac..ab6aa18c4d2 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -1,6 +1,6 @@ { "domain": "generic", - "name": "Generic", + "name": "Generic Camera", "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": [], "iot_class": "local_push" diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py new file mode 100644 index 00000000000..568863adb73 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -0,0 +1,78 @@ +"""The generic_hygrostat component.""" + +import logging + +import voluptuous as vol + +from homeassistant.components.humidifier.const import ( + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, +) +from homeassistant.const import CONF_NAME +from homeassistant.helpers import config_validation as cv, discovery + +DOMAIN = "generic_hygrostat" + +_LOGGER = logging.getLogger(__name__) + +CONF_HUMIDIFIER = "humidifier" +CONF_SENSOR = "target_sensor" +CONF_MIN_HUMIDITY = "min_humidity" +CONF_MAX_HUMIDITY = "max_humidity" +CONF_TARGET_HUMIDITY = "target_humidity" +CONF_DEVICE_CLASS = "device_class" +CONF_MIN_DUR = "min_cycle_duration" +CONF_DRY_TOLERANCE = "dry_tolerance" +CONF_WET_TOLERANCE = "wet_tolerance" +CONF_KEEP_ALIVE = "keep_alive" +CONF_INITIAL_STATE = "initial_state" +CONF_AWAY_HUMIDITY = "away_humidity" +CONF_AWAY_FIXED = "away_fixed" +CONF_STALE_DURATION = "sensor_stale_duration" + +DEFAULT_TOLERANCE = 3 +DEFAULT_NAME = "Generic Hygrostat" + +HYGROSTAT_SCHEMA = vol.Schema( + { + vol.Required(CONF_HUMIDIFIER): cv.entity_id, + vol.Required(CONF_SENSOR): cv.entity_id, + vol.Optional(CONF_DEVICE_CLASS): vol.In( + [DEVICE_CLASS_HUMIDIFIER, DEVICE_CLASS_DEHUMIDIFIER] + ), + vol.Optional(CONF_MAX_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MIN_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DRY_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_WET_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float), + vol.Optional(CONF_TARGET_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_KEEP_ALIVE): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_INITIAL_STATE): cv.boolean, + vol.Optional(CONF_AWAY_HUMIDITY): vol.Coerce(int), + vol.Optional(CONF_AWAY_FIXED): cv.boolean, + vol.Optional(CONF_STALE_DURATION): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [HYGROSTAT_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Generic Hygrostat component.""" + if DOMAIN not in config: + return True + + for hygrostat_conf in config[DOMAIN]: + hass.async_create_task( + discovery.async_load_platform( + hass, "humidifier", DOMAIN, hygrostat_conf, config + ) + ) + + return True diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py new file mode 100644 index 00000000000..ee1c8f65d1a --- /dev/null +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -0,0 +1,465 @@ +"""Adds support for generic hygrostat units.""" +import asyncio +import logging + +from homeassistant.components.humidifier import PLATFORM_SCHEMA, HumidifierEntity +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + MODE_AWAY, + MODE_NORMAL, + SUPPORT_MODES, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + CONF_NAME, + EVENT_HOMEASSISTANT_START, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import DOMAIN as HA_DOMAIN, callback +from homeassistant.helpers import condition +from homeassistant.helpers.event import ( + async_track_state_change, + async_track_time_interval, +) +from homeassistant.helpers.restore_state import RestoreEntity + +from . import ( + CONF_AWAY_FIXED, + CONF_AWAY_HUMIDITY, + CONF_DEVICE_CLASS, + CONF_DRY_TOLERANCE, + CONF_HUMIDIFIER, + CONF_INITIAL_STATE, + CONF_KEEP_ALIVE, + CONF_MAX_HUMIDITY, + CONF_MIN_DUR, + CONF_MIN_HUMIDITY, + CONF_SENSOR, + CONF_STALE_DURATION, + CONF_TARGET_HUMIDITY, + CONF_WET_TOLERANCE, + HYGROSTAT_SCHEMA, +) + +_LOGGER = logging.getLogger(__name__) + +ATTR_SAVED_HUMIDITY = "saved_humidity" + +SUPPORT_FLAGS = 0 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(HYGROSTAT_SCHEMA.schema) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the generic hygrostat platform.""" + if discovery_info: + config = discovery_info + name = config[CONF_NAME] + switch_entity_id = config[CONF_HUMIDIFIER] + sensor_entity_id = config[CONF_SENSOR] + min_humidity = config.get(CONF_MIN_HUMIDITY) + max_humidity = config.get(CONF_MAX_HUMIDITY) + target_humidity = config.get(CONF_TARGET_HUMIDITY) + device_class = config.get(CONF_DEVICE_CLASS) + min_cycle_duration = config.get(CONF_MIN_DUR) + sensor_stale_duration = config.get(CONF_STALE_DURATION) + dry_tolerance = config[CONF_DRY_TOLERANCE] + wet_tolerance = config[CONF_WET_TOLERANCE] + keep_alive = config.get(CONF_KEEP_ALIVE) + initial_state = config.get(CONF_INITIAL_STATE) + away_humidity = config.get(CONF_AWAY_HUMIDITY) + away_fixed = config.get(CONF_AWAY_FIXED) + + async_add_entities( + [ + GenericHygrostat( + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ) + ] + ) + + +class GenericHygrostat(HumidifierEntity, RestoreEntity): + """Representation of a Generic Hygrostat device.""" + + def __init__( + self, + name, + switch_entity_id, + sensor_entity_id, + min_humidity, + max_humidity, + target_humidity, + device_class, + min_cycle_duration, + dry_tolerance, + wet_tolerance, + keep_alive, + initial_state, + away_humidity, + away_fixed, + sensor_stale_duration, + ): + """Initialize the hygrostat.""" + self._name = name + self._switch_entity_id = switch_entity_id + self._sensor_entity_id = sensor_entity_id + self._device_class = device_class + self._min_cycle_duration = min_cycle_duration + self._dry_tolerance = dry_tolerance + self._wet_tolerance = wet_tolerance + self._keep_alive = keep_alive + self._state = initial_state + self._saved_target_humidity = away_humidity or target_humidity + self._active = False + self._cur_humidity = None + self._humidity_lock = asyncio.Lock() + self._min_humidity = min_humidity + self._max_humidity = max_humidity + self._target_humidity = target_humidity + self._support_flags = SUPPORT_FLAGS + if away_humidity: + self._support_flags = SUPPORT_FLAGS | SUPPORT_MODES + self._away_humidity = away_humidity + self._away_fixed = away_fixed + self._sensor_stale_duration = sensor_stale_duration + self._remove_stale_tracking = None + self._is_away = False + if not self._device_class: + self._device_class = DEVICE_CLASS_HUMIDIFIER + + async def async_added_to_hass(self): + """Run when entity about to be added.""" + await super().async_added_to_hass() + + # Add listener + async_track_state_change( + self.hass, self._sensor_entity_id, self._async_sensor_changed + ) + async_track_state_change( + self.hass, self._switch_entity_id, self._async_switch_changed + ) + + if self._keep_alive: + async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + + @callback + async def _async_startup(event): + """Init on startup.""" + sensor_state = self.hass.states.get(self._sensor_entity_id) + await self._async_sensor_changed(self._sensor_entity_id, None, sensor_state) + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_startup) + + old_state = await self.async_get_last_state() + if old_state is not None: + if old_state.attributes.get(ATTR_MODE) == MODE_AWAY: + self._is_away = True + self._saved_target_humidity = self._target_humidity + self._target_humidity = self._away_humidity or self._target_humidity + if old_state.attributes.get(ATTR_HUMIDITY): + self._target_humidity = int(old_state.attributes[ATTR_HUMIDITY]) + if old_state.attributes.get(ATTR_SAVED_HUMIDITY): + self._saved_target_humidity = int( + old_state.attributes[ATTR_SAVED_HUMIDITY] + ) + if old_state.state: + self._state = old_state.state == STATE_ON + if self._target_humidity is None: + if self._device_class == DEVICE_CLASS_HUMIDIFIER: + self._target_humidity = self.min_humidity + else: + self._target_humidity = self.max_humidity + _LOGGER.warning( + "No previously saved humidity, setting to %s", self._target_humidity + ) + if self._state is None: + self._state = False + + await _async_startup(None) # init the sensor + + @property + def available(self): + """Return True if entity is available.""" + return self._active + + @property + def state_attributes(self): + """Return the optional state attributes.""" + data = super().state_attributes + + if self._saved_target_humidity: + data[ATTR_SAVED_HUMIDITY] = self._saved_target_humidity + + return data + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the hygrostat.""" + return self._name + + @property + def is_on(self): + """Return true if the hygrostat is on.""" + return self._state + + @property + def target_humidity(self): + """Return the humidity we try to reach.""" + return self._target_humidity + + @property + def mode(self): + """Return the current mode.""" + if self._away_humidity is None: + return None + if self._is_away: + return MODE_AWAY + return MODE_NORMAL + + @property + def available_modes(self): + """Return a list of available modes.""" + if self._away_humidity: + return [MODE_NORMAL, MODE_AWAY] + return None + + @property + def device_class(self): + """Return the device class of the humidifier.""" + return self._device_class + + async def async_turn_on(self, **kwargs): + """Turn hygrostat on.""" + if not self._active: + return + self._state = True + await self._async_operate(force=True) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn hygrostat off.""" + if not self._active: + return + self._state = False + if self._is_device_active: + await self._async_device_turn_off() + await self.async_update_ha_state() + + async def async_set_humidity(self, humidity: int): + """Set new target humidity.""" + if humidity is None: + return + + if self._is_away and self._away_fixed: + self._saved_target_humidity = humidity + await self.async_update_ha_state() + return + + self._target_humidity = humidity + await self._async_operate(force=True) + await self.async_update_ha_state() + + @property + def min_humidity(self): + """Return the minimum humidity.""" + if self._min_humidity: + return self._min_humidity + + # get default humidity from super class + return super().min_humidity + + @property + def max_humidity(self): + """Return the maximum humidity.""" + if self._max_humidity: + return self._max_humidity + + # Get default humidity from super class + return super().max_humidity + + @callback + async def _async_sensor_changed(self, entity_id, old_state, new_state): + """Handle ambient humidity changes.""" + if new_state is None: + return + + if self._sensor_stale_duration: + if self._remove_stale_tracking: + self._remove_stale_tracking() + self._remove_stale_tracking = async_track_time_interval( + self.hass, + self._async_sensor_not_responding, + self._sensor_stale_duration, + ) + + await self._async_update_humidity(new_state.state) + await self._async_operate() + await self.async_update_ha_state() + + @callback + async def _async_sensor_not_responding(self, now=None): + """Handle sensor stale event.""" + + _LOGGER.debug( + "Sensor has not been updated for %s", + now - self.hass.states.get(self._sensor_entity_id).last_updated, + ) + _LOGGER.warning("Sensor is stalled, call the emergency stop") + await self._async_update_humidity("Stalled") + + @callback + def _async_switch_changed(self, entity_id, old_state, new_state): + """Handle humidifier switch state changes.""" + if new_state is None: + return + self.async_schedule_update_ha_state() + + async def _async_update_humidity(self, humidity): + """Update hygrostat with latest state from sensor.""" + try: + self._cur_humidity = float(humidity) + except ValueError as ex: + _LOGGER.warning("Unable to update from sensor: %s", ex) + self._cur_humidity = None + self._active = False + if self._is_device_active: + await self._async_device_turn_off() + + async def _async_operate(self, time=None, force=False): + """Check if we need to turn humidifying on or off.""" + async with self._humidity_lock: + if not self._active and None not in ( + self._cur_humidity, + self._target_humidity, + ): + self._active = True + force = True + _LOGGER.info( + "Obtained current and target humidity. " + "Generic hygrostat active. %s, %s", + self._cur_humidity, + self._target_humidity, + ) + + if not self._active or not self._state: + return + + if not force and time is None: + # If the `force` argument is True, we + # ignore `min_cycle_duration`. + # If the `time` argument is not none, we were invoked for + # keep-alive purposes, and `min_cycle_duration` is irrelevant. + if self._min_cycle_duration: + if self._is_device_active: + current_state = STATE_ON + else: + current_state = STATE_OFF + long_enough = condition.state( + self.hass, + self._switch_entity_id, + current_state, + self._min_cycle_duration, + ) + if not long_enough: + return + + if force: + # Ignore the tolerance when switched on manually + dry_tolerance = 0 + wet_tolerance = 0 + else: + dry_tolerance = self._dry_tolerance + wet_tolerance = self._wet_tolerance + + too_dry = self._target_humidity - self._cur_humidity >= dry_tolerance + too_wet = self._cur_humidity - self._target_humidity >= wet_tolerance + if self._is_device_active: + if (self._device_class == DEVICE_CLASS_HUMIDIFIER and too_wet) or ( + self._device_class == DEVICE_CLASS_DEHUMIDIFIER and too_dry + ): + _LOGGER.info("Turning off humidifier %s", self._switch_entity_id) + await self._async_device_turn_off() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_on() + else: + if (self._device_class == DEVICE_CLASS_HUMIDIFIER and too_dry) or ( + self._device_class == DEVICE_CLASS_DEHUMIDIFIER and too_wet + ): + _LOGGER.info("Turning on humidifier %s", self._switch_entity_id) + await self._async_device_turn_on() + elif time is not None: + # The time argument is passed only in keep-alive case + await self._async_device_turn_off() + + @property + def _is_device_active(self): + """If the toggleable device is currently active.""" + return self.hass.states.is_state(self._switch_entity_id, STATE_ON) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + async def _async_device_turn_on(self): + """Turn humidifier toggleable device on.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data) + + async def _async_device_turn_off(self): + """Turn humidifier toggleable device off.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data) + + async def async_set_mode(self, mode: str): + """Set new mode. + + This method must be run in the event loop and returns a coroutine. + """ + if self._away_humidity is None: + return + if mode == MODE_AWAY and not self._is_away: + self._is_away = True + if not self._saved_target_humidity: + self._saved_target_humidity = self._away_humidity + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + elif mode == MODE_NORMAL and self._is_away: + self._is_away = False + self._saved_target_humidity, self._target_humidity = ( + self._target_humidity, + self._saved_target_humidity, + ) + await self._async_operate(force=True) + + await self.async_update_ha_state() diff --git a/homeassistant/components/generic_hygrostat/manifest.json b/homeassistant/components/generic_hygrostat/manifest.json new file mode 100644 index 00000000000..5874097dc84 --- /dev/null +++ b/homeassistant/components/generic_hygrostat/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "generic_hygrostat", + "name": "Generic hygrostat", + "documentation": "https://www.home-assistant.io/integrations/generic_hygrostat", + "codeowners": ["@Shulyaka"], + "quality_scale": "internal", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/generic_hygrostat/services.yaml b/homeassistant/components/generic_hygrostat/services.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bf5fc03ded5..cad80e8d707 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -120,7 +120,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_track_time_interval(hass, broker.async_update, SCAN_INTERVAL) - for platform in ["climate", "water_heater", "sensor", "binary_sensor", "switch"]: + for platform in ("climate", "water_heater", "sensor", "binary_sensor", "switch"): hass.async_create_task(async_load_platform(hass, platform, DOMAIN, {}, config)) setup_service_functions(hass, broker) diff --git a/homeassistant/components/geonetnz_quakes/translations/de.json b/homeassistant/components/geonetnz_quakes/translations/de.json index 583712c6c4e..2bfc3f2dbbd 100644 --- a/homeassistant/components/geonetnz_quakes/translations/de.json +++ b/homeassistant/components/geonetnz_quakes/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Standort ist bereits konfiguriert." + "already_configured": "Der Dienst ist bereits konfiguriert" }, "step": { "user": { diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index ab956fe9da7..c3227254075 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -9,8 +9,10 @@ from aiohttp.client_exceptions import ClientConnectorError from async_timeout import timeout from gios import ApiError, Gios, InvalidSensorsData, NoStationError +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -19,7 +21,7 @@ from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["air_quality"] +PLATFORMS = ["sensor"] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -49,6 +51,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) + # Remove air_quality entities from registry if they exist + ent_reg = entity_registry.async_get(hass) + unique_id = str(coordinator.gios.station_id) + if entity_id := ent_reg.async_get_entity_id( + AIR_QUALITY_PLATFORM, DOMAIN, unique_id + ): + _LOGGER.debug("Removing deprecated air_quality entity %s", entity_id) + ent_reg.async_remove(entity_id) + return True diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py deleted file mode 100644 index 00c4a526c46..00000000000 --- a/homeassistant/components/gios/air_quality.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Support for the GIOS service.""" -from __future__ import annotations - -from typing import Any, Optional, cast - -from homeassistant.components.air_quality import AirQualityEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.entity_registry import async_get_registry -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import GiosDataUpdateCoordinator -from .const import ( - API_AQI, - API_CO, - API_NO2, - API_O3, - API_PM10, - API_PM25, - API_SO2, - ATTR_STATION, - ATTRIBUTION, - DEFAULT_NAME, - DOMAIN, - ICONS_MAP, - MANUFACTURER, - SENSOR_MAP, -) - -PARALLEL_UPDATES = 1 - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Add a GIOS entities from a config_entry.""" - name = entry.data[CONF_NAME] - - coordinator = hass.data[DOMAIN][entry.entry_id] - - # We used to use int as entity unique_id, convert this to str. - entity_registry = await async_get_registry(hass) - old_entity_id = entity_registry.async_get_entity_id( - "air_quality", DOMAIN, coordinator.gios.station_id - ) - if old_entity_id is not None: - entity_registry.async_update_entity( - old_entity_id, new_unique_id=str(coordinator.gios.station_id) - ) - - async_add_entities([GiosAirQuality(coordinator, name)]) - - -class GiosAirQuality(CoordinatorEntity, AirQualityEntity): - """Define an GIOS sensor.""" - - coordinator: GiosDataUpdateCoordinator - - def __init__(self, coordinator: GiosDataUpdateCoordinator, name: str) -> None: - """Initialize.""" - super().__init__(coordinator) - self._name = name - self._attrs: dict[str, Any] = {} - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def icon(self) -> str: - """Return the icon.""" - if self.air_quality_index is not None and self.air_quality_index in ICONS_MAP: - return ICONS_MAP[self.air_quality_index] - return "mdi:blur" - - @property - def air_quality_index(self) -> str | None: - """Return the air quality index.""" - return cast(Optional[str], self.coordinator.data.get(API_AQI).get("value")) - - @property - def particulate_matter_2_5(self) -> float | None: - """Return the particulate matter 2.5 level.""" - return round_state(self._get_sensor_value(API_PM25)) - - @property - def particulate_matter_10(self) -> float | None: - """Return the particulate matter 10 level.""" - return round_state(self._get_sensor_value(API_PM10)) - - @property - def ozone(self) -> float | None: - """Return the O3 (ozone) level.""" - return round_state(self._get_sensor_value(API_O3)) - - @property - def carbon_monoxide(self) -> float | None: - """Return the CO (carbon monoxide) level.""" - return round_state(self._get_sensor_value(API_CO)) - - @property - def sulphur_dioxide(self) -> float | None: - """Return the SO2 (sulphur dioxide) level.""" - return round_state(self._get_sensor_value(API_SO2)) - - @property - def nitrogen_dioxide(self) -> float | None: - """Return the NO2 (nitrogen dioxide) level.""" - return round_state(self._get_sensor_value(API_NO2)) - - @property - def attribution(self) -> str: - """Return the attribution.""" - return ATTRIBUTION - - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return str(self.coordinator.gios.station_id) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return { - "identifiers": {(DOMAIN, str(self.coordinator.gios.station_id))}, - "name": DEFAULT_NAME, - "manufacturer": MANUFACTURER, - "entry_type": "service", - } - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return the state attributes.""" - # Different measuring stations have different sets of sensors. We don't know - # what data we will get. - for sensor in SENSOR_MAP: - if sensor in self.coordinator.data: - self._attrs[f"{SENSOR_MAP[sensor]}_index"] = self.coordinator.data[ - sensor - ].get("index") - self._attrs[ATTR_STATION] = self.coordinator.gios.station_name - return self._attrs - - def _get_sensor_value(self, sensor: str) -> float | None: - """Return value of specified sensor.""" - if sensor in self.coordinator.data: - return cast(float, self.coordinator.data[sensor]["value"]) - return None - - -def round_state(state: float | None) -> float | None: - """Round state.""" - return round(state) if state is not None else None diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py index 161dc1b0add..ff3f33408a5 100644 --- a/homeassistant/components/gios/config_flow.py +++ b/homeassistant/components/gios/config_flow.py @@ -14,14 +14,7 @@ from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import API_TIMEOUT, CONF_STATION_ID, DEFAULT_NAME, DOMAIN - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_STATION_ID): int, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, - } -) +from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -48,8 +41,9 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): gios = Gios(user_input[CONF_STATION_ID], websession) await gios.async_update() + assert gios.station_name is not None return self.async_create_entry( - title=user_input[CONF_STATION_ID], + title=gios.station_name, data=user_input, ) except (ApiError, ClientConnectorError, asyncio.TimeoutError): @@ -60,5 +54,14 @@ class GiosFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors[CONF_STATION_ID] = "invalid_sensors_data" return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_STATION_ID): int, + vol.Optional( + CONF_NAME, default=self.hass.config.location_name + ): str, + } + ), + errors=errors, ) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index d16225d90a7..9b890442166 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -4,18 +4,13 @@ from __future__ import annotations from datetime import timedelta from typing import Final -from homeassistant.components.air_quality import ( - ATTR_CO, - ATTR_NO2, - ATTR_OZONE, - ATTR_PM_2_5, - ATTR_PM_10, - ATTR_SO2, -) +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + +from .model import GiosSensorEntityDescription ATTRIBUTION: Final = "Data provided by GIOŚ" -ATTR_STATION: Final = "station" CONF_STATION_ID: Final = "station_id" DEFAULT_NAME: Final = "GIOŚ" # Term of service GIOŚ allow downloading data no more than twice an hour. @@ -23,35 +18,66 @@ SCAN_INTERVAL: Final = timedelta(minutes=30) DOMAIN: Final = "gios" MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska" -API_AQI: Final = "aqi" -API_CO: Final = "co" -API_NO2: Final = "no2" -API_O3: Final = "o3" -API_PM10: Final = "pm10" -API_PM25: Final = "pm2.5" -API_SO2: Final = "so2" - API_TIMEOUT: Final = 30 -AQI_GOOD: Final = "dobry" -AQI_MODERATE: Final = "umiarkowany" -AQI_POOR: Final = "dostateczny" -AQI_VERY_GOOD: Final = "bardzo dobry" -AQI_VERY_POOR: Final = "zły" +ATTR_INDEX: Final = "index" +ATTR_STATION: Final = "station" -ICONS_MAP: Final[dict[str, str]] = { - AQI_VERY_GOOD: "mdi:emoticon-excited", - AQI_GOOD: "mdi:emoticon-happy", - AQI_MODERATE: "mdi:emoticon-neutral", - AQI_POOR: "mdi:emoticon-sad", - AQI_VERY_POOR: "mdi:emoticon-dead", -} +ATTR_C6H6: Final = "c6h6" +ATTR_CO: Final = "co" +ATTR_NO2: Final = "no2" +ATTR_O3: Final = "o3" +ATTR_PM10: Final = "pm10" +ATTR_PM25: Final = "pm25" +ATTR_SO2: Final = "so2" +ATTR_AQI: Final = "aqi" -SENSOR_MAP: Final[dict[str, str]] = { - API_CO: ATTR_CO, - API_NO2: ATTR_NO2, - API_O3: ATTR_OZONE, - API_PM10: ATTR_PM_10, - API_PM25: ATTR_PM_2_5, - API_SO2: ATTR_SO2, -} +SENSOR_TYPES: Final[tuple[GiosSensorEntityDescription, ...]] = ( + GiosSensorEntityDescription( + key=ATTR_AQI, + name="AQI", + value=None, + ), + GiosSensorEntityDescription( + key=ATTR_C6H6, + name="C6H6", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_CO, + name="CO", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_NO2, + name="NO2", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_O3, + name="O3", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_PM10, + name="PM10", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_PM25, + name="PM2.5", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), + GiosSensorEntityDescription( + key=ATTR_SO2, + name="SO2", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=STATE_CLASS_MEASUREMENT, + ), +) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index f13da0e3f33..3e7bf9aceca 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -3,7 +3,7 @@ "name": "GIO\u015a", "documentation": "https://www.home-assistant.io/integrations/gios", "codeowners": ["@bieniu"], - "requirements": ["gios==1.0.2"], + "requirements": ["gios==2.0.0"], "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/homeassistant/components/gios/model.py b/homeassistant/components/gios/model.py new file mode 100644 index 00000000000..b6ae9a9f78f --- /dev/null +++ b/homeassistant/components/gios/model.py @@ -0,0 +1,14 @@ +"""Type definitions for GIOS integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from homeassistant.components.sensor import SensorEntityDescription + + +@dataclass +class GiosSensorEntityDescription(SensorEntityDescription): + """Class describing GIOS sensor entities.""" + + value: Callable | None = round diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py new file mode 100644 index 00000000000..b651112b9db --- /dev/null +++ b/homeassistant/components/gios/sensor.py @@ -0,0 +1,133 @@ +"""Support for the GIOS service.""" +from __future__ import annotations + +import logging +from typing import Any, cast + +from homeassistant.components.sensor import DOMAIN as PLATFORM, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_NAME, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GiosDataUpdateCoordinator +from .const import ( + ATTR_AQI, + ATTR_INDEX, + ATTR_PM25, + ATTR_STATION, + ATTRIBUTION, + DEFAULT_NAME, + DOMAIN, + MANUFACTURER, + SENSOR_TYPES, +) +from .model import GiosSensorEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add a GIOS entities from a config_entry.""" + name = entry.data[CONF_NAME] + + coordinator = hass.data[DOMAIN][entry.entry_id] + + # Due to the change of the attribute name of one sensor, it is necessary to migrate + # the unique_id to the new name. + entity_registry = await async_get_registry(hass) + old_unique_id = f"{coordinator.gios.station_id}-pm2.5" + if entity_id := entity_registry.async_get_entity_id( + PLATFORM, DOMAIN, old_unique_id + ): + new_unique_id = f"{coordinator.gios.station_id}-{ATTR_PM25}" + _LOGGER.debug( + "Migrating entity %s from old unique ID '%s' to new unique ID '%s'", + entity_id, + old_unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + + sensors: list[GiosSensor | GiosAqiSensor] = [] + + for description in SENSOR_TYPES: + if getattr(coordinator.data, description.key) is None: + continue + if description.key == ATTR_AQI: + sensors.append(GiosAqiSensor(name, coordinator, description)) + else: + sensors.append(GiosSensor(name, coordinator, description)) + async_add_entities(sensors) + + +class GiosSensor(CoordinatorEntity, SensorEntity): + """Define an GIOS sensor.""" + + coordinator: GiosDataUpdateCoordinator + entity_description: GiosSensorEntityDescription + + def __init__( + self, + name: str, + coordinator: GiosDataUpdateCoordinator, + description: GiosSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = { + "identifiers": {(DOMAIN, str(coordinator.gios.station_id))}, + "name": DEFAULT_NAME, + "manufacturer": MANUFACTURER, + "entry_type": "service", + } + self._attr_icon = "mdi:blur" + self._attr_name = f"{name} {description.name}" + self._attr_unique_id = f"{coordinator.gios.station_id}-{description.key}" + self._attrs: dict[str, Any] = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_STATION: self.coordinator.gios.station_name, + } + self.entity_description = description + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + self._attrs[ATTR_NAME] = getattr( + self.coordinator.data, self.entity_description.key + ).name + self._attrs[ATTR_INDEX] = getattr( + self.coordinator.data, self.entity_description.key + ).index + return self._attrs + + @property + def state(self) -> StateType: + """Return the state.""" + state = getattr(self.coordinator.data, self.entity_description.key).value + assert self.entity_description.value is not None + return cast(StateType, self.entity_description.value(state)) + + +class GiosAqiSensor(GiosSensor): + """Define an GIOS AQI sensor.""" + + @property + def state(self) -> StateType: + """Return the state.""" + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.key).value + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + available = super().available + return available and bool( + getattr(self.coordinator.data, self.entity_description.key) + ) diff --git a/homeassistant/components/gios/translations/de.json b/homeassistant/components/gios/translations/de.json index e1351278f38..99548187601 100644 --- a/homeassistant/components/gios/translations/de.json +++ b/homeassistant/components/gios/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "GIO\u015a integration f\u00fcr diese Messstation ist bereits konfiguriert. " + "already_configured": "Standort ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/gios/translations/hu.json b/homeassistant/components/gios/translations/hu.json index b35904e9d76..9454aceb13d 100644 --- a/homeassistant/components/gios/translations/hu.json +++ b/homeassistant/components/gios/translations/hu.json @@ -18,5 +18,10 @@ "title": "GIO\u015a (Lengyel K\u00f6rnyezetv\u00e9delmi F\u0151fel\u00fcgyel\u0151s\u00e9g)" } } + }, + "system_health": { + "info": { + "can_reach_server": "\u00c9rje el a GIO\u015a szervert" + } } } \ No newline at end of file diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 18865a232d7..b74662db22b 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,7 +1,17 @@ """Constants for Glances component.""" +from __future__ import annotations + +from dataclasses import dataclass import sys -from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, PERCENTAGE, TEMP_CELSIUS +from homeassistant.components.sensor import SensorEntityDescription +from homeassistant.const import ( + DATA_GIBIBYTES, + DATA_MEBIBYTES, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_CELSIUS, +) DOMAIN = "glances" CONF_VERSION = "version" @@ -20,32 +30,168 @@ if sys.maxsize > 2 ** 32: else: CPU_ICON = "mdi:cpu-32-bit" -SENSOR_TYPES = { - "disk_use_percent": ["fs", "used percent", PERCENTAGE, "mdi:harddisk"], - "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"], - "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"], - "memory_use_percent": ["mem", "RAM used percent", PERCENTAGE, "mdi:memory"], - "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"], - "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"], - "swap_use_percent": ["memswap", "Swap used percent", PERCENTAGE, "mdi:memory"], - "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"], - "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"], - "processor_load": ["load", "CPU load", "15 min", CPU_ICON], - "process_running": ["processcount", "Running", "Count", CPU_ICON], - "process_total": ["processcount", "Total", "Count", CPU_ICON], - "process_thread": ["processcount", "Thread", "Count", CPU_ICON], - "process_sleeping": ["processcount", "Sleeping", "Count", CPU_ICON], - "cpu_use_percent": ["cpu", "CPU used", PERCENTAGE, CPU_ICON], - "temperature_core": ["sensors", "Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "temperature_hdd": ["sensors", "Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "fan_speed": ["sensors", "Fan speed", "RPM", "mdi:fan"], - "battery": ["sensors", "Charge", PERCENTAGE, "mdi:battery"], - "docker_active": ["docker", "Containers active", "", "mdi:docker"], - "docker_cpu_use": ["docker", "Containers CPU used", PERCENTAGE, "mdi:docker"], - "docker_memory_use": [ - "docker", - "Containers RAM used", - DATA_MEBIBYTES, - "mdi:docker", - ], -} + +@dataclass +class GlancesSensorEntityDescription(SensorEntityDescription): + """Describe Glances sensor entity.""" + + type: str | None = None + name_suffix: str | None = None + + +SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( + GlancesSensorEntityDescription( + key="disk_use_percent", + type="fs", + name_suffix="used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="disk_use", + type="fs", + name_suffix="used", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="disk_free", + type="fs", + name_suffix="free", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:harddisk", + ), + GlancesSensorEntityDescription( + key="memory_use_percent", + type="mem", + name_suffix="RAM used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="memory_use", + type="mem", + name_suffix="RAM used", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="memory_free", + type="mem", + name_suffix="RAM free", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_use_percent", + type="memswap", + name_suffix="Swap used percent", + unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_use", + type="memswap", + name_suffix="Swap used", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="swap_free", + type="memswap", + name_suffix="Swap free", + unit_of_measurement=DATA_GIBIBYTES, + icon="mdi:memory", + ), + GlancesSensorEntityDescription( + key="processor_load", + type="load", + name_suffix="CPU load", + unit_of_measurement="15 min", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_running", + type="processcount", + name_suffix="Running", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_total", + type="processcount", + name_suffix="Total", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_thread", + type="processcount", + name_suffix="Thread", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="process_sleeping", + type="processcount", + name_suffix="Sleeping", + unit_of_measurement="Count", + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="cpu_use_percent", + type="cpu", + name_suffix="CPU used", + unit_of_measurement=PERCENTAGE, + icon=CPU_ICON, + ), + GlancesSensorEntityDescription( + key="temperature_core", + type="sensors", + name_suffix="Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + GlancesSensorEntityDescription( + key="temperature_hdd", + type="sensors", + name_suffix="Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + GlancesSensorEntityDescription( + key="fan_speed", + type="sensors", + name_suffix="Fan speed", + unit_of_measurement="RPM", + icon="mdi:fan", + ), + GlancesSensorEntityDescription( + key="battery", + type="sensors", + name_suffix="Charge", + unit_of_measurement=PERCENTAGE, + icon="mdi:battery", + ), + GlancesSensorEntityDescription( + key="docker_active", + type="docker", + name_suffix="Containers active", + unit_of_measurement="", + icon="mdi:docker", + ), + GlancesSensorEntityDescription( + key="docker_cpu_use", + type="docker", + name_suffix="Containers CPU used", + unit_of_measurement=PERCENTAGE, + icon="mdi:docker", + ), + GlancesSensorEntityDescription( + key="docker_memory_use", + type="docker", + name_suffix="Containers RAM used", + unit_of_measurement=DATA_MEBIBYTES, + icon="mdi:docker", + ), +) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 7e599af414c..fd31ee37faf 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -4,7 +4,7 @@ from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES +from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorEntityDescription async def async_setup_entry(hass, config_entry, async_add_entities): @@ -14,45 +14,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): name = config_entry.data[CONF_NAME] dev = [] - for sensor_type, sensor_details in SENSOR_TYPES.items(): - if sensor_details[0] not in client.api.data: - continue - if sensor_details[0] == "fs": + for description in SENSOR_TYPES: + if description.type == "fs": # fs will provide a list of disks attached - for disk in client.api.data[sensor_details[0]]: + for disk in client.api.data[description.type]: dev.append( GlancesSensor( client, name, disk["mnt_point"], - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) - elif sensor_details[0] == "sensors": + elif description.type == "sensors": # sensors will provide temp for different devices - for sensor in client.api.data[sensor_details[0]]: - if sensor["type"] == sensor_type: + for sensor in client.api.data[description.type]: + if sensor["type"] == description.key: dev.append( GlancesSensor( client, name, sensor["label"], - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) - elif client.api.data[sensor_details[0]]: + elif client.api.data[description.type]: dev.append( GlancesSensor( client, name, "", - SENSOR_TYPES[sensor_type][1], - sensor_type, - SENSOR_TYPES[sensor_type], + description, ) ) @@ -62,45 +54,29 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class GlancesSensor(SensorEntity): """Implementation of a Glances sensor.""" + entity_description: GlancesSensorEntityDescription + def __init__( self, glances_data, name, sensor_name_prefix, - sensor_name_suffix, - sensor_type, - sensor_details, + description: GlancesSensorEntityDescription, ): """Initialize the sensor.""" self.glances_data = glances_data self._sensor_name_prefix = sensor_name_prefix - self._sensor_name_suffix = sensor_name_suffix - self._name = name - self.type = sensor_type self._state = None - self.sensor_details = sensor_details self.unsub_update = None - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._sensor_name_prefix} {self._sensor_name_suffix}" + self.entity_description = description + self._attr_name = f"{name} {sensor_name_prefix} {description.name_suffix}" @property def unique_id(self): """Set unique_id for sensor.""" return f"{self.glances_data.host}-{self.name}" - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self.sensor_details[3] - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self.sensor_details[2] - @property def available(self): """Could the device be accessed during the last update call.""" @@ -138,12 +114,12 @@ class GlancesSensor(SensorEntity): if value is None: return - if self.sensor_details[0] == "fs": + if self.entity_description.type == "fs": for var in value["fs"]: if var["mnt_point"] == self._sensor_name_prefix: disk = var break - if self.type == "disk_free": + if self.entity_description.key == "disk_free": try: self._state = round(disk["free"] / 1024 ** 3, 1) except KeyError: @@ -151,67 +127,67 @@ class GlancesSensor(SensorEntity): (disk["size"] - disk["used"]) / 1024 ** 3, 1, ) - elif self.type == "disk_use": + elif self.entity_description.key == "disk_use": self._state = round(disk["used"] / 1024 ** 3, 1) - elif self.type == "disk_use_percent": + elif self.entity_description.key == "disk_use_percent": self._state = disk["percent"] - elif self.type == "battery": + elif self.entity_description.key == "battery": for sensor in value["sensors"]: if ( sensor["type"] == "battery" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "fan_speed": + elif self.entity_description.key == "fan_speed": for sensor in value["sensors"]: if ( sensor["type"] == "fan_speed" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "temperature_core": + elif self.entity_description.key == "temperature_core": for sensor in value["sensors"]: if ( sensor["type"] == "temperature_core" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "temperature_hdd": + elif self.entity_description.key == "temperature_hdd": for sensor in value["sensors"]: if ( sensor["type"] == "temperature_hdd" and sensor["label"] == self._sensor_name_prefix ): self._state = sensor["value"] - elif self.type == "memory_use_percent": + elif self.entity_description.key == "memory_use_percent": self._state = value["mem"]["percent"] - elif self.type == "memory_use": + elif self.entity_description.key == "memory_use": self._state = round(value["mem"]["used"] / 1024 ** 2, 1) - elif self.type == "memory_free": + elif self.entity_description.key == "memory_free": self._state = round(value["mem"]["free"] / 1024 ** 2, 1) - elif self.type == "swap_use_percent": + elif self.entity_description.key == "swap_use_percent": self._state = value["memswap"]["percent"] - elif self.type == "swap_use": + elif self.entity_description.key == "swap_use": self._state = round(value["memswap"]["used"] / 1024 ** 3, 1) - elif self.type == "swap_free": + elif self.entity_description.key == "swap_free": self._state = round(value["memswap"]["free"] / 1024 ** 3, 1) - elif self.type == "processor_load": + elif self.entity_description.key == "processor_load": # Windows systems don't provide load details try: self._state = value["load"]["min15"] except KeyError: self._state = value["cpu"]["total"] - elif self.type == "process_running": + elif self.entity_description.key == "process_running": self._state = value["processcount"]["running"] - elif self.type == "process_total": + elif self.entity_description.key == "process_total": self._state = value["processcount"]["total"] - elif self.type == "process_thread": + elif self.entity_description.key == "process_thread": self._state = value["processcount"]["thread"] - elif self.type == "process_sleeping": + elif self.entity_description.key == "process_sleeping": self._state = value["processcount"]["sleeping"] - elif self.type == "cpu_use_percent": + elif self.entity_description.key == "cpu_use_percent": self._state = value["quicklook"]["cpu"] - elif self.type == "docker_active": + elif self.entity_description.key == "docker_active": count = 0 try: for container in value["docker"]["containers"]: @@ -220,7 +196,7 @@ class GlancesSensor(SensorEntity): self._state = count except KeyError: self._state = count - elif self.type == "docker_cpu_use": + elif self.entity_description.key == "docker_cpu_use": cpu_use = 0.0 try: for container in value["docker"]["containers"]: @@ -229,7 +205,7 @@ class GlancesSensor(SensorEntity): self._state = round(cpu_use, 1) except KeyError: self._state = STATE_UNAVAILABLE - elif self.type == "docker_memory_use": + elif self.entity_description.key == "docker_memory_use": mem_use = 0.0 try: for container in value["docker"]["containers"]: diff --git a/homeassistant/components/glances/translations/de.json b/homeassistant/components/glances/translations/de.json index e464bfdee34..8c91e4fb2e3 100644 --- a/homeassistant/components/glances/translations/de.json +++ b/homeassistant/components/glances/translations/de.json @@ -14,9 +14,9 @@ "name": "Name", "password": "Passwort", "port": "Port", - "ssl": "Verwende SSL / TLS, um eine Verbindung zum Glances-System herzustellen", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "\u00dcberpr\u00fcfe die Zertifizierung des Systems", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen", "version": "Glances API-Version (2 oder 3)" }, "title": "Glances einrichten" diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index 8838d3f20fa..308934819cd 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,27 +1,46 @@ """The Goal Zero Yeti integration.""" +from __future__ import annotations + import logging from goalzero import Yeti, exceptions from homeassistant.components.binary_sensor import DOMAIN as DOMAIN_BINARY_SENSOR +from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_HOST, + CONF_NAME, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, UpdateFailed, ) -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from .const import ( + ATTRIBUTION, + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, +) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [DOMAIN_BINARY_SENSOR, DOMAIN_SWITCH] +PLATFORMS = [DOMAIN_BINARY_SENSOR, DOMAIN_SENSOR, DOMAIN_SWITCH] async def async_setup_entry(hass, entry): @@ -34,7 +53,7 @@ async def async_setup_entry(hass, entry): try: await api.init_connect() except exceptions.ConnectError as ex: - _LOGGER.warning("Failed to connect: %s", ex) + _LOGGER.warning("Failed to connect to device %s", ex) raise ConfigEntryNotReady from ex async def async_update_data(): @@ -42,7 +61,7 @@ async def async_setup_entry(hass, entry): try: await api.get_state() except exceptions.ConnectError as err: - raise UpdateFailed(f"Failed to communicating with device {err}") from err + raise UpdateFailed("Failed to communicate with device") from err coordinator = DataUpdateCoordinator( hass, @@ -73,29 +92,27 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class YetiEntity(CoordinatorEntity): """Representation of a Goal Zero Yeti entity.""" + _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + def __init__(self, api, coordinator, name, server_unique_id): """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) self.api = api self._name = name self._server_unique_id = server_unique_id - self._device_class = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - info = { - "identifiers": {(DOMAIN, self._server_unique_id)}, - "manufacturer": "Goal Zero", - "name": self._name, - } + model = sw_version = None if self.api.sysdata: - info["model"] = self.api.sysdata["model"] + model = self.api.sysdata[ATTR_MODEL] if self.api.data: - info["sw_version"] = self.api.data["firmwareVersion"] - return info - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class + sw_version = self.api.data["firmwareVersion"] + return { + ATTR_IDENTIFIERS: {(DOMAIN, self._server_unique_id)}, + ATTR_MANUFACTURER: "Goal Zero", + ATTR_NAME: self._name, + ATTR_MODEL: str(model), + ATTR_SW_VERSION: str(sw_version), + } diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 59a8a6b3443..f9a110eff55 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -1,6 +1,6 @@ """Support for Goal Zero Yeti Sensors.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.const import CONF_NAME +from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_NAME, CONF_NAME from . import YetiEntity from .const import BINARY_SENSOR_DICT, DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN @@ -12,7 +12,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the Goal Zero Yeti sensor.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - sensors = [ + async_add_entities( YetiBinarySensor( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], @@ -21,8 +21,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entry.entry_id, ) for sensor_name in BINARY_SENSOR_DICT - ] - async_add_entities(sensors, True) + ) class YetiBinarySensor(YetiEntity, BinarySensorEntity): @@ -40,30 +39,16 @@ class YetiBinarySensor(YetiEntity, BinarySensorEntity): super().__init__(api, coordinator, name, server_unique_id) self._condition = sensor_name - - variable_info = BINARY_SENSOR_DICT[sensor_name] - self._condition_name = variable_info[0] - self._icon = variable_info[2] - self._device_class = variable_info[1] + self._attr_device_class = BINARY_SENSOR_DICT[sensor_name].get(ATTR_DEVICE_CLASS) + self._attr_icon = BINARY_SENSOR_DICT[sensor_name].get(ATTR_ICON) + self._attr_name = f"{name} {BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" + self._attr_unique_id = ( + f"{server_unique_id}/{BINARY_SENSOR_DICT[sensor_name].get(ATTR_NAME)}" + ) @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"{self._server_unique_id}/{self._condition_name}" - - @property - def is_on(self): + def is_on(self) -> bool: """Return if the service is on.""" if self.api.data: return self.api.data[self._condition] == 1 return False - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon diff --git a/homeassistant/components/goalzero/config_flow.py b/homeassistant/components/goalzero/config_flow.py index cea47c967a8..4c525de9c7d 100644 --- a/homeassistant/components/goalzero/config_flow.py +++ b/homeassistant/components/goalzero/config_flow.py @@ -18,8 +18,6 @@ from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -DATA_SCHEMA = vol.Schema({vol.Required("host"): str, vol.Required("name"): str}) - class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Goal Zero Yeti.""" @@ -65,7 +63,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} if user_input is not None: @@ -100,7 +98,7 @@ class GoalZeroFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_try_connect(self, host): + async def _async_try_connect(self, host) -> tuple: """Try connecting to Goal Zero Yeti.""" try: session = async_get_clientsession(self.hass) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index 826c2621e23..e9fed7dc52b 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -6,6 +6,34 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_POWER, ) +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, + SIGNAL_STRENGTH_DECIBELS, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, +) + +ATTRIBUTION = "Data provided by Goal Zero" +ATTR_DEFAULT_ENABLED = "default_enabled" DATA_KEY_COORDINATOR = "coordinator" DOMAIN = "goalzero" @@ -15,14 +43,107 @@ DATA_KEY_API = "api" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) BINARY_SENSOR_DICT = { - "backlight": ["Backlight", None, "mdi:clock-digital"], - "app_online": [ - "App Online", - DEVICE_CLASS_CONNECTIVITY, - None, - ], - "isCharging": ["Charging", DEVICE_CLASS_BATTERY_CHARGING, None], - "inputDetected": ["Input Detected", DEVICE_CLASS_POWER, None], + "backlight": {ATTR_NAME: "Backlight", ATTR_ICON: "mdi:clock-digital"}, + "app_online": { + ATTR_NAME: "App Online", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CONNECTIVITY, + }, + "isCharging": { + ATTR_NAME: "Charging", + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING, + }, + "inputDetected": { + ATTR_NAME: "Input Detected", + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + }, +} + +SENSOR_DICT = { + "wattsIn": { + ATTR_NAME: "Watts In", + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: True, + }, + "ampsIn": { + ATTR_NAME: "Amps In", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: False, + }, + "wattsOut": { + ATTR_NAME: "Watts Out", + ATTR_DEVICE_CLASS: DEVICE_CLASS_POWER, + ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: True, + }, + "ampsOut": { + ATTR_NAME: "Amps Out", + ATTR_DEVICE_CLASS: DEVICE_CLASS_CURRENT, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: False, + }, + "whOut": { + ATTR_NAME: "WH Out", + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: False, + }, + "whStored": { + ATTR_NAME: "WH Stored", + ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_WATT_HOUR, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, + ATTR_DEFAULT_ENABLED: True, + }, + "volts": { + ATTR_NAME: "Volts", + ATTR_DEVICE_CLASS: DEVICE_CLASS_VOLTAGE, + ATTR_UNIT_OF_MEASUREMENT: ELECTRIC_POTENTIAL_VOLT, + ATTR_DEFAULT_ENABLED: False, + }, + "socPercent": { + ATTR_NAME: "State of Charge Percent", + ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY, + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_DEFAULT_ENABLED: True, + }, + "timeToEmptyFull": { + ATTR_NAME: "Time to Empty/Full", + ATTR_DEVICE_CLASS: TIME_MINUTES, + ATTR_UNIT_OF_MEASUREMENT: TIME_MINUTES, + ATTR_DEFAULT_ENABLED: True, + }, + "temperature": { + ATTR_NAME: "Temperature", + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + ATTR_DEFAULT_ENABLED: True, + }, + "wifiStrength": { + ATTR_NAME: "Wifi Strength", + ATTR_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH, + ATTR_UNIT_OF_MEASUREMENT: SIGNAL_STRENGTH_DECIBELS, + ATTR_DEFAULT_ENABLED: True, + }, + "timestamp": { + ATTR_NAME: "Total Run Time", + ATTR_UNIT_OF_MEASUREMENT: TIME_SECONDS, + ATTR_DEFAULT_ENABLED: False, + }, + "ssid": { + ATTR_NAME: "Wi-Fi SSID", + ATTR_DEFAULT_ENABLED: False, + }, + "ipAddr": { + ATTR_NAME: "IP Address", + ATTR_DEFAULT_ENABLED: False, + }, } SWITCH_DICT = { diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py new file mode 100644 index 00000000000..594e1f0046b --- /dev/null +++ b/homeassistant/components/goalzero/sensor.py @@ -0,0 +1,60 @@ +"""Support for Goal Zero Yeti Sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ATTR_LAST_RESET, ATTR_STATE_CLASS +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, + CONF_NAME, +) + +from . import YetiEntity +from .const import ( + ATTR_DEFAULT_ENABLED, + DATA_KEY_API, + DATA_KEY_COORDINATOR, + DOMAIN, + SENSOR_DICT, +) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Goal Zero Yeti sensor.""" + name = entry.data[CONF_NAME] + goalzero_data = hass.data[DOMAIN][entry.entry_id] + sensors = [ + YetiSensor( + goalzero_data[DATA_KEY_API], + goalzero_data[DATA_KEY_COORDINATOR], + name, + sensor_name, + entry.entry_id, + ) + for sensor_name in SENSOR_DICT + ] + async_add_entities(sensors, True) + + +class YetiSensor(YetiEntity): + """Representation of a Goal Zero Yeti sensor.""" + + def __init__(self, api, coordinator, name, sensor_name, server_unique_id): + """Initialize a Goal Zero Yeti sensor.""" + super().__init__(api, coordinator, name, server_unique_id) + self._condition = sensor_name + sensor = SENSOR_DICT[sensor_name] + self._attr_device_class = sensor.get(ATTR_DEVICE_CLASS) + self._attr_entity_registry_enabled_default = sensor.get(ATTR_DEFAULT_ENABLED) + self._attr_last_reset = sensor.get(ATTR_LAST_RESET) + self._attr_name = f"{name} {sensor.get(ATTR_NAME)}" + self._attr_state_class = sensor.get(ATTR_STATE_CLASS) + self._attr_unique_id = f"{server_unique_id}/{sensor_name}" + self._attr_unit_of_measurement = sensor.get(ATTR_UNIT_OF_MEASUREMENT) + + @property + def state(self) -> str | None: + """Return the state.""" + if self.api.data: + return self.api.data.get(self._condition) + return None diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index dd4c9deae3e..9d37bcb0b7b 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -1,4 +1,6 @@ """Support for Goal Zero Yeti Switches.""" +from __future__ import annotations + from homeassistant.components.switch import SwitchEntity from homeassistant.const import CONF_NAME @@ -10,7 +12,7 @@ async def async_setup_entry(hass, entry, async_add_entities): """Set up the Goal Zero Yeti switch.""" name = entry.data[CONF_NAME] goalzero_data = hass.data[DOMAIN][entry.entry_id] - switches = [ + async_add_entities( YetiSwitch( goalzero_data[DATA_KEY_API], goalzero_data[DATA_KEY_COORDINATOR], @@ -19,8 +21,7 @@ async def async_setup_entry(hass, entry, async_add_entities): entry.entry_id, ) for switch_name in SWITCH_DICT - ] - async_add_entities(switches) + ) class YetiSwitch(YetiEntity, SwitchEntity): @@ -36,27 +37,16 @@ class YetiSwitch(YetiEntity, SwitchEntity): ): """Initialize a Goal Zero Yeti switch.""" super().__init__(api, coordinator, name, server_unique_id) - self._condition = switch_name - - self._condition_name = SWITCH_DICT[switch_name] + self._attr_name = f"{name} {SWITCH_DICT[switch_name]}" + self._attr_unique_id = f"{server_unique_id}/{switch_name}" @property - def name(self): - """Return the name of the switch.""" - return f"{self._name} {self._condition_name}" - - @property - def unique_id(self): - """Return the unique id of the switch.""" - return f"{self._server_unique_id}/{self._condition}" - - @property - def is_on(self): + def is_on(self) -> bool: """Return state of the switch.""" if self.api.data: return self.api.data[self._condition] - return None + return False async def async_turn_off(self, **kwargs): """Turn off the switch.""" diff --git a/homeassistant/components/goalzero/translations/de.json b/homeassistant/components/goalzero/translations/de.json index 6f6eb052589..5133488c247 100644 --- a/homeassistant/components/goalzero/translations/de.json +++ b/homeassistant/components/goalzero/translations/de.json @@ -7,12 +7,12 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_host": "Ung\u00fcltiger Hostname oder IP Adresse", + "invalid_host": "Ung\u00fcltiger Hostname oder IP-Adresse", "unknown": "Unerwarteter Fehler" }, "step": { "confirm_discovery": { - "description": "Eine DHCP-Reservierung auf Ihrem Router wird empfohlen. Wenn sie nicht eingerichtet ist, ist das Ger\u00e4t m\u00f6glicherweise nicht mehr verf\u00fcgbar, bis Home Assistant die neue IP-Adresse erkennt. Schlagen Sie im Benutzerhandbuch Ihres Routers nach.", + "description": "Eine DHCP-Reservierung auf deinem Router wird empfohlen. Wenn sie nicht eingerichtet ist, ist das Ger\u00e4t m\u00f6glicherweise nicht mehr verf\u00fcgbar, bis Home Assistant die neue IP-Adresse erkennt. Schlage im Benutzerhandbuch deines Routers nach.", "title": "Goal Zero Yeti" }, "user": { diff --git a/homeassistant/components/goalzero/translations/fr.json b/homeassistant/components/goalzero/translations/fr.json index 7bd4929ad92..bb6a777b6be 100644 --- a/homeassistant/components/goalzero/translations/fr.json +++ b/homeassistant/components/goalzero/translations/fr.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "invalid_host": "Nom d'h\u00f4te ou adresse IP non valide", + "unknown": "Erreur inattendue" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -9,6 +11,10 @@ "unknown": "Erreur inconnue" }, "step": { + "confirm_discovery": { + "description": "La r\u00e9servation DHCP sur votre routeur est recommand\u00e9e. S'il n'est pas configur\u00e9, l'appareil peut devenir indisponible jusqu'\u00e0 ce que Home Assistant d\u00e9tecte la nouvelle adresse IP. Reportez-vous au manuel d'utilisation de votre routeur.", + "title": "Objectif Z\u00e9ro Y\u00e9ti" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/goalzero/translations/hu.json b/homeassistant/components/goalzero/translations/hu.json index ebc56c1bbe5..f8c507a6625 100644 --- a/homeassistant/components/goalzero/translations/hu.json +++ b/homeassistant/components/goalzero/translations/hu.json @@ -11,11 +11,17 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "confirm_discovery": { + "description": "DHCP foglal\u00e1s aj\u00e1nlott az \u00fatv\u00e1laszt\u00f3n. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "title": "Goal Zero Yeti" + }, "user": { "data": { "host": "Hoszt", "name": "N\u00e9v" - } + }, + "description": "El\u0151sz\u00f6r le kell t\u00f6ltenie a Goal Zero alkalmaz\u00e1st: https://www.goalzero.com/product-features/yeti-app/ \n\nK\u00f6vesse az utas\u00edt\u00e1sokat, hogy csatlakoztassa Yeti k\u00e9sz\u00fcl\u00e9k\u00e9t a Wi-Fi h\u00e1l\u00f3zathoz. DHCP foglal\u00e1s aj\u00e1nlott az \u00fatv\u00e1laszt\u00f3n. Ha nincs be\u00e1ll\u00edtva, akkor az eszk\u00f6z el\u00e9rhetetlenn\u00e9 v\u00e1lhat, am\u00edg a Home Assistant \u00e9szleli az \u00faj IP-c\u00edmet. Olvassa el az \u00fatv\u00e1laszt\u00f3 felhaszn\u00e1l\u00f3i k\u00e9zik\u00f6nyv\u00e9t.", + "title": "Goal Zero Yeti" } } } diff --git a/homeassistant/components/goalzero/translations/id.json b/homeassistant/components/goalzero/translations/id.json index 63fddf13a8e..5bab8fa03a2 100644 --- a/homeassistant/components/goalzero/translations/id.json +++ b/homeassistant/components/goalzero/translations/id.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "Akun sudah dikonfigurasi" + "already_configured": "Akun sudah dikonfigurasi", + "invalid_host": "Nama host atau alamat IP tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" }, "error": { "cannot_connect": "Gagal terhubung", diff --git a/homeassistant/components/goalzero/translations/nl.json b/homeassistant/components/goalzero/translations/nl.json index 4f902f8bea2..d73c4d648ed 100644 --- a/homeassistant/components/goalzero/translations/nl.json +++ b/homeassistant/components/goalzero/translations/nl.json @@ -20,7 +20,7 @@ "host": "Host", "name": "Naam" }, - "description": "Eerst moet u de Goal Zero-app downloaden: https://www.goalzero.com/product-features/yeti-app/ \n\n Volg de instructies om je Yeti te verbinden met je wifi-netwerk. Haal dan de host-ip van uw router. DHCP moet zijn ingesteld in uw routerinstellingen voor het apparaat om ervoor te zorgen dat het host-ip niet verandert. Raadpleeg de gebruikershandleiding van uw router.", + "description": "Eerst moet u de Goal Zero-app downloaden: https://www.goalzero.com/product-features/yeti-app/ \n\n Volg de instructies om Yeti te verbinden met uw wifi-netwerk. DHCP-reservering op uw router wordt aanbevolen. Als het niet is ingesteld, is het apparaat mogelijk niet meer beschikbaar totdat Home Assistant het nieuwe IP-adres detecteert. Raadpleeg de gebruikershandleiding van uw router.", "title": "Goal Zero Yeti" } } diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py index d4271b3937a..f4ff18b0837 100644 --- a/homeassistant/components/gogogate2/__init__.py +++ b/homeassistant/components/gogogate2/__init__.py @@ -18,13 +18,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Update the config entry. config_updates = {} if CONF_DEVICE not in entry.data: - config_updates["data"] = { + config_updates = { **entry.data, **{CONF_DEVICE: DEVICE_TYPE_GOGOGATE2}, } if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry(entry, data=config_updates) data_update_coordinator = get_data_update_coordinator(hass, entry) await data_update_coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 5f37b135ace..c1f81f8fd32 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -1,10 +1,10 @@ """Common code for GogoGate2 component.""" from __future__ import annotations -from collections.abc import Awaitable +from collections.abc import Awaitable, Mapping from datetime import timedelta import logging -from typing import Callable, NamedTuple +from typing import Any, Callable, NamedTuple from ismartgate import AbstractGateApi, GogoGate2Api, ISmartGateApi from ismartgate.common import AbstractDoor, get_door_by_id @@ -149,7 +149,7 @@ def sensor_unique_id( return f"{config_entry.unique_id}_{door.door_id}_{sensor_type}" -def get_api(hass: HomeAssistant, config_data: dict) -> AbstractGateApi: +def get_api(hass: HomeAssistant, config_data: Mapping[str, Any]) -> AbstractGateApi: """Get an api object for config data.""" gate_class = GogoGate2Api diff --git a/homeassistant/components/gogogate2/translations/fr.json b/homeassistant/components/gogogate2/translations/fr.json index 79f216738c4..94cee628a79 100644 --- a/homeassistant/components/gogogate2/translations/fr.json +++ b/homeassistant/components/gogogate2/translations/fr.json @@ -7,6 +7,7 @@ "cannot_connect": "\u00c9chec de connexion", "invalid_auth": "Authentification invalide" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/id.json b/homeassistant/components/gogogate2/translations/id.json index 9de61641d41..89d25d74a48 100644 --- a/homeassistant/components/gogogate2/translations/id.json +++ b/homeassistant/components/gogogate2/translations/id.json @@ -7,6 +7,7 @@ "cannot_connect": "Gagal terhubung", "invalid_auth": "Autentikasi tidak valid" }, + "flow_title": "{device} ({ip_address})", "step": { "user": { "data": { diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index b46d48848da..6cc7221ba1d 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,5 +1,6 @@ """Support for Google - Calendar Event Devices.""" from datetime import datetime, timedelta +from enum import Enum import logging import os @@ -41,6 +42,7 @@ CONF_TRACK = "track" CONF_SEARCH = "search" CONF_IGNORE_AVAILABILITY = "ignore_availability" CONF_MAX_RESULTS = "max_results" +CONF_CALENDAR_ACCESS = "calendar_access" DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = "!!" @@ -70,10 +72,26 @@ SERVICE_ADD_EVENT = "add_event" DATA_INDEX = "google_calendars" YAML_DEVICES = f"{DOMAIN}_calendars.yaml" -SCOPES = "https://www.googleapis.com/auth/calendar" TOKEN_FILE = f".{DOMAIN}.token" + +class FeatureAccess(Enum): + """Class to represent different access scopes.""" + + read_only = "https://www.googleapis.com/auth/calendar.readonly" + read_write = "https://www.googleapis.com/auth/calendar" + + def __init__(self, scope: str) -> None: + """Init instance.""" + self._scope = scope + + @property + def scope(self) -> str: + """Google calendar scope for the feature.""" + return self._scope + + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -81,6 +99,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, vol.Optional(CONF_TRACK_NEW): cv.boolean, + vol.Optional(CONF_CALENDAR_ACCESS, default="read_write"): cv.enum( + FeatureAccess + ), } ) }, @@ -139,7 +160,7 @@ def do_authentication(hass, hass_config, config): oauth = OAuth2WebServerFlow( client_id=config[CONF_CLIENT_ID], client_secret=config[CONF_CLIENT_SECRET], - scope="https://www.googleapis.com/auth/calendar", + scope=config[CONF_CALENDAR_ACCESS].scope, redirect_uri="Home-Assistant.io", ) try: @@ -213,7 +234,7 @@ def setup(hass, config): if not os.path.isfile(token_file): do_authentication(hass, config, conf) else: - if not check_correct_scopes(token_file): + if not check_correct_scopes(token_file, conf): do_authentication(hass, config, conf) else: do_setup(hass, config, conf) @@ -221,16 +242,22 @@ def setup(hass, config): return True -def check_correct_scopes(token_file): +def check_correct_scopes(token_file, config): """Check for the correct scopes in file.""" - tokenfile = open(token_file).read() - if "readonly" in tokenfile: - _LOGGER.warning("Please re-authenticate with Google") - return False + with open(token_file, encoding="utf8") as tokenfile: + contents = tokenfile.read() + + # Check for quoted scope as our scopes can be subsets of other scopes + target_scope = f'"{config.get(CONF_CALENDAR_ACCESS).scope}"' + if target_scope not in contents: + _LOGGER.warning("Please re-authenticate with Google") + return False return True -def setup_services(hass, hass_config, track_new_found_calendars, calendar_service): +def setup_services( + hass, hass_config, config, track_new_found_calendars, calendar_service +): """Set up the service listeners.""" def _found_calendar(call): @@ -312,9 +339,11 @@ def setup_services(hass, hass_config, track_new_found_calendars, calendar_servic service_data = {"calendarId": call.data[EVENT_CALENDAR_ID], "body": event} event = service.events().insert(**service_data).execute() - hass.services.register( - DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA - ) + # Only expose the add event service if we have the correct permissions + if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write: + hass.services.register( + DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA + ) return True @@ -327,7 +356,9 @@ def do_setup(hass, hass_config, config): track_new_found_calendars = convert( config.get(CONF_TRACK_NEW), bool, DEFAULT_CONF_TRACK_NEW ) - setup_services(hass, hass_config, track_new_found_calendars, calendar_service) + setup_services( + hass, hass_config, config, track_new_found_calendars, calendar_service + ) for calendar in hass.data[DATA_INDEX].values(): discovery.load_platform(hass, "calendar", DOMAIN, calendar, hass_config) @@ -377,7 +408,7 @@ def load_config(path): """Load the google_calendar_devices.yaml.""" calendars = {} try: - with open(path) as file: + with open(path, encoding="utf8") as file: data = yaml.safe_load(file) for calendar in data: try: @@ -394,6 +425,6 @@ def load_config(path): def update_config(path, calendar): """Write the google_calendar_devices.yaml.""" - with open(path, "a") as out: + with open(path, "a", encoding="utf8") as out: out.write("\n") yaml.dump([calendar], out, default_flow_style=False) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 9b6f7d77f26..e96cf4ec0c6 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -1,7 +1,7 @@ { "domain": "google", "name": "Google Calendars", - "documentation": "https://www.home-assistant.io/integrations/google", + "documentation": "https://www.home-assistant.io/integrations/calendar.google/", "requirements": [ "google-api-python-client==1.6.4", "httplib2==0.19.0", diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 885e79994ff..ebbed89347e 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -212,10 +212,10 @@ class AbstractConfig(ABC): async def async_sync_entities_all(self): """Sync all entities to Google for all registered agents.""" res = await gather( - *[ + *( self.async_sync_entities(agent_user_id) for agent_user_id in self._store.agent_user_ids - ] + ) ) return max(res, default=204) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 747dc234efe..dc55509b534 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -213,10 +213,10 @@ async def handle_devices_execute(hass, data, payload): executions[entity_id] = [execution] execute_results = await asyncio.gather( - *[ - _entity_execute(entities[entity_id], data, executions[entity_id]) - for entity_id in executions - ] + *( + _entity_execute(entities[entity_id], data, execution) + for entity_id, execution in executions.items() + ) ) for entity_id, result in zip(executions, execute_results): diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 0c547f18741..36222902296 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -24,6 +24,7 @@ from homeassistant.components import ( ) from homeassistant.components.climate import const as climate from homeassistant.components.humidifier import const as humidifier +from homeassistant.components.lock import STATE_JAMMED, STATE_UNLOCKING from homeassistant.components.media_player.const import MEDIA_TYPE_CHANNEL from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -141,6 +142,7 @@ COMMAND_MEDIA_SEEK_RELATIVE = f"{PREFIX_COMMANDS}mediaSeekRelative" COMMAND_MEDIA_SEEK_TO_POSITION = f"{PREFIX_COMMANDS}mediaSeekToPosition" COMMAND_MEDIA_SHUFFLE = f"{PREFIX_COMMANDS}mediaShuffle" COMMAND_MEDIA_STOP = f"{PREFIX_COMMANDS}mediaStop" +COMMAND_REVERSE = f"{PREFIX_COMMANDS}Reverse" COMMAND_SET_HUMIDITY = f"{PREFIX_COMMANDS}SetHumidity" COMMAND_SELECT_CHANNEL = f"{PREFIX_COMMANDS}selectChannel" @@ -1101,7 +1103,11 @@ class LockUnlockTrait(_Trait): def query_attributes(self): """Return LockUnlock query attributes.""" - return {"isLocked": self.state.state == STATE_LOCKED} + if self.state.state == STATE_JAMMED: + return {"isJammed": True} + + # If its unlocking its not yet unlocked so we consider is locked + return {"isLocked": self.state.state in (STATE_UNLOCKING, STATE_LOCKED)} async def execute(self, command, data, params, challenge): """Execute an LockUnlock command.""" @@ -1253,14 +1259,7 @@ class FanSpeedTrait(_Trait): """ name = TRAIT_FANSPEED - commands = [COMMAND_FANSPEED] - - speed_synonyms = { - fan.SPEED_OFF: ["stop", "off"], - fan.SPEED_LOW: ["slow", "low", "slowest", "lowest"], - fan.SPEED_MEDIUM: ["medium", "mid", "middle"], - fan.SPEED_HIGH: ["high", "max", "fast", "highest", "fastest", "maximum"], - } + commands = [COMMAND_FANSPEED, COMMAND_REVERSE] @staticmethod def supported(domain, features, device_class, _): @@ -1275,23 +1274,21 @@ class FanSpeedTrait(_Trait): """Return speed point and modes attributes for a sync request.""" domain = self.state.domain speeds = [] - reversible = False + result = {} if domain == fan.DOMAIN: - # The use of legacy speeds is deprecated in the schema, support will be removed after a quarter (2021.7) - modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) - for mode in modes: - speed = { - "speed_name": mode, - "speed_values": [ - {"speed_synonym": self.speed_synonyms.get(mode), "lang": "en"} - ], - } - speeds.append(speed) reversible = bool( self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION ) + + result.update( + { + "reversible": reversible, + "supportsFanSpeedPercent": True, + } + ) + elif domain == climate.DOMAIN: modes = self.state.attributes.get(climate.ATTR_FAN_MODES, []) for mode in modes: @@ -1301,32 +1298,32 @@ class FanSpeedTrait(_Trait): } speeds.append(speed) - return { - "availableFanSpeeds": {"speeds": speeds, "ordered": True}, - "reversible": reversible, - "supportsFanSpeedPercent": True, - } + result.update( + { + "reversible": False, + "availableFanSpeeds": {"speeds": speeds, "ordered": True}, + } + ) + + return result def query_attributes(self): """Return speed point and modes query attributes.""" + attrs = self.state.attributes domain = self.state.domain response = {} if domain == climate.DOMAIN: - speed = attrs.get(climate.ATTR_FAN_MODE) - if speed is not None: - response["currentFanSpeedSetting"] = speed + speed = attrs.get(climate.ATTR_FAN_MODE) or "off" + response["currentFanSpeedSetting"] = speed + if domain == fan.DOMAIN: - speed = attrs.get(fan.ATTR_SPEED) percent = attrs.get(fan.ATTR_PERCENTAGE) or 0 - if speed is not None: - response["on"] = speed != fan.SPEED_OFF - response["currentFanSpeedSetting"] = speed - if percent is not None: - response["currentFanSpeedPercent"] = percent + response["currentFanSpeedPercent"] = percent + return response - async def execute(self, command, data, params, challenge): + async def execute_fanspeed(self, data, params): """Execute an SetFanSpeed command.""" domain = self.state.domain if domain == climate.DOMAIN: @@ -1340,25 +1337,43 @@ class FanSpeedTrait(_Trait): blocking=True, context=data.context, ) - if domain == fan.DOMAIN: - service_params = { - ATTR_ENTITY_ID: self.state.entity_id, - } - if "fanSpeedPercent" in params: - service = fan.SERVICE_SET_PERCENTAGE - service_params[fan.ATTR_PERCENTAGE] = params["fanSpeedPercent"] - else: - service = fan.SERVICE_SET_SPEED - service_params[fan.ATTR_SPEED] = params["fanSpeed"] + if domain == fan.DOMAIN: await self.hass.services.async_call( fan.DOMAIN, - service, - service_params, + fan.SERVICE_SET_PERCENTAGE, + { + ATTR_ENTITY_ID: self.state.entity_id, + fan.ATTR_PERCENTAGE: params["fanSpeedPercent"], + }, blocking=True, context=data.context, ) + async def execute_reverse(self, data, params): + """Execute a Reverse command.""" + domain = self.state.domain + if domain == fan.DOMAIN: + if self.state.attributes.get(fan.ATTR_DIRECTION) == fan.DIRECTION_FORWARD: + direction = fan.DIRECTION_REVERSE + else: + direction = fan.DIRECTION_FORWARD + + await self.hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_DIRECTION: direction}, + blocking=True, + context=data.context, + ) + + async def execute(self, command, data, params, challenge): + """Execute a smart home command.""" + if command == COMMAND_FANSPEED: + await self.execute_fanspeed(data, params) + elif command == COMMAND_REVERSE: + await self.execute_reverse(data, params) + @register_trait class ModesTrait(_Trait): diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py index 365c118e99e..514b919e877 100644 --- a/homeassistant/components/google_pubsub/__init__.py +++ b/homeassistant/components/google_pubsub/__init__.py @@ -59,7 +59,9 @@ def setup(hass: HomeAssistant, yaml_config: dict[str, Any]): service_principal_path ) - topic_path = publisher.topic_path(project_id, topic_name) + topic_path = publisher.topic_path( # pylint: disable=no-member + project_id, topic_name + ) encoder = DateTimeJSONEncoder() diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 425d21ee181..cf5f6e8b0af 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -41,7 +41,7 @@ def get_location_from_entity(hass, logger, entity_id): return get_location_from_attributes(entity) # Check if device is in a zone - zone_entity = hass.states.get("zone.%s" % entity.state) + zone_entity = hass.states.get(f"zone.{entity.state}") if location.has_location(zone_entity): logger.debug( "%s is in %s, getting zone location", entity_id, zone_entity.entity_id diff --git a/homeassistant/components/google_travel_time/translations/de.json b/homeassistant/components/google_travel_time/translations/de.json index 4e89ad7da1a..701935f53fe 100644 --- a/homeassistant/components/google_travel_time/translations/de.json +++ b/homeassistant/components/google_travel_time/translations/de.json @@ -14,7 +14,7 @@ "name": "Name", "origin": "Startort" }, - "description": "Bei der Angabe von Ursprung und Ziel k\u00f6nnen Sie einen oder mehrere durch das Pipe-Zeichen getrennte Orte in Form einer Adresse, L\u00e4ngen- / Breitengradkoordinaten oder einer Google-Orts-ID angeben. Wenn Sie den Standort mithilfe einer Google-Orts-ID angeben, muss der ID \"place_id:\" vorangestellt werden." + "description": "Bei der Angabe von Ursprung und Ziel kannst du einen oder mehrere durch das Pipe-Zeichen getrennte Orte in Form einer Adresse, L\u00e4ngen- / Breitengradkoordinaten oder einer Google-Orts-ID angeben. Wenn du den Standort mithilfe einer Google-Orts-ID angibst, muss der ID \"place_id:\" vorangestellt werden." } } }, @@ -31,7 +31,7 @@ "transit_routing_preference": "Transit-Routing-Einstellungen", "units": "Einheiten" }, - "description": "Sie k\u00f6nnen optional entweder eine Abfahrtszeit oder eine Ankunftszeit angeben. Wenn Sie eine Abfahrtszeit angeben, k\u00f6nnen Sie \"Now\", einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" eingeben. Wenn Sie eine Ankunftszeit angeben, k\u00f6nnen Sie einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" verwenden." + "description": "Du kannst optional entweder eine Abfahrtszeit oder eine Ankunftszeit angeben. Wenn du eine Abfahrtszeit angibst, kannst du \"Now\", einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" eingeben. Wenn du eine Ankunftszeit angibst, kannst du einen Unix-Zeitstempel oder eine 24-Stunden-Zeichenkette wie \"08:00:00\" verwenden." } } }, diff --git a/homeassistant/components/google_travel_time/translations/hu.json b/homeassistant/components/google_travel_time/translations/hu.json index 5bee8045c4f..85a15a98e58 100644 --- a/homeassistant/components/google_travel_time/translations/hu.json +++ b/homeassistant/components/google_travel_time/translations/hu.json @@ -11,6 +11,7 @@ "data": { "api_key": "Api kucs", "destination": "C\u00e9l", + "name": "N\u00e9v", "origin": "Eredet" }, "description": "Az eredet \u00e9s a c\u00e9l megad\u00e1sakor megadhat egy vagy t\u00f6bb helyet a pipa karakterrel elv\u00e1lasztva, c\u00edm, sz\u00e9less\u00e9gi / hossz\u00fas\u00e1gi koordin\u00e1t\u00e1k vagy Google helyazonos\u00edt\u00f3 form\u00e1j\u00e1ban. Amikor a helyet megadja egy Google helyazonos\u00edt\u00f3val, akkor az azonos\u00edt\u00f3t el\u0151taggal kell ell\u00e1tni a `hely_azonos\u00edt\u00f3:` sz\u00f3val." diff --git a/homeassistant/components/google_travel_time/translations/id.json b/homeassistant/components/google_travel_time/translations/id.json index 3973d673f8e..16b60148aa9 100644 --- a/homeassistant/components/google_travel_time/translations/id.json +++ b/homeassistant/components/google_travel_time/translations/id.json @@ -11,6 +11,7 @@ "data": { "api_key": "Kunci API", "destination": "Tujuan", + "name": "Nama", "origin": "Asal" }, "description": "Saat menentukan asal dan tujuan, Anda dapat menyediakan satu atau beberapa lokasi yang dipisahkan oleh karakter pipe, dalam bentuk alamat, koordinat lintang/bujur, atau ID tempat Google. Saat menentukan lokasi menggunakan ID tempat Google, ID harus diawali dengan \"place_id:'." diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py index acd57ef590d..73ea66e5895 100644 --- a/homeassistant/components/gree/climate.py +++ b/homeassistant/components/gree/climate.py @@ -159,8 +159,8 @@ class GreeClimateEntity(CoordinatorEntity, ClimateEntity): @property def current_temperature(self) -> float: - """Return the target temperature, gree devices don't provide internal temp.""" - return self.target_temperature + """Return the reported current temperature for the device.""" + return self.coordinator.device.current_temperature @property def target_temperature(self) -> float: diff --git a/homeassistant/components/gree/entity.py b/homeassistant/components/gree/entity.py new file mode 100644 index 00000000000..0753a780f4b --- /dev/null +++ b/homeassistant/components/gree/entity.py @@ -0,0 +1,37 @@ +"""Entity object for shared properties of Gree entities.""" +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .bridge import DeviceDataUpdateCoordinator +from .const import DOMAIN + + +class GreeEntity(CoordinatorEntity): + """Generic Gree entity (base class).""" + + def __init__(self, coordinator: DeviceDataUpdateCoordinator, desc: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._desc = desc + self._name = f"{coordinator.device.device_info.name}" + self._mac = coordinator.device.device_info.mac + + @property + def name(self): + """Return the name of the node.""" + return f"{self._name} {self._desc}" + + @property + def unique_id(self): + """Return the unique id based for the node.""" + return f"{self._mac}_{self._desc}" + + @property + def device_info(self): + """Return info about the device.""" + return { + "identifiers": {(DOMAIN, self._mac)}, + "name": self._name, + "manufacturer": "Gree", + "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, + } diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json index 8108df18cc8..e62bf402523 100644 --- a/homeassistant/components/gree/manifest.json +++ b/homeassistant/components/gree/manifest.json @@ -3,7 +3,7 @@ "name": "Gree Climate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gree", - "requirements": ["greeclimate==0.11.7"], + "requirements": ["greeclimate==0.11.8"], "codeowners": ["@cmroche"], "iot_class": "local_polling" } diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py index 7f659d7e64b..f8a5b4c0b3d 100644 --- a/homeassistant/components/gree/switch.py +++ b/homeassistant/components/gree/switch.py @@ -3,11 +3,10 @@ from __future__ import annotations from homeassistant.components.switch import DEVICE_CLASS_SWITCH, SwitchEntity from homeassistant.core import callback -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DISPATCHERS, DOMAIN +from .entity import GreeEntity async def async_setup_entry(hass, config_entry, async_add_entities): @@ -16,7 +15,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def init_device(coordinator): """Register the device.""" - async_add_entities([GreeSwitchEntity(coordinator)]) + async_add_entities( + [ + GreePanelLightSwitchEntity(coordinator), + GreeQuietModeSwitchEntity(coordinator), + GreeFreshAirSwitchEntity(coordinator), + GreeXFanSwitchEntity(coordinator), + ] + ) for coordinator in hass.data[DOMAIN][COORDINATORS]: init_device(coordinator) @@ -26,40 +32,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -class GreeSwitchEntity(CoordinatorEntity, SwitchEntity): - """Representation of a Gree HVAC device.""" +class GreePanelLightSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the front panel light on the device.""" def __init__(self, coordinator): """Initialize the Gree device.""" - super().__init__(coordinator) - self._name = coordinator.device.device_info.name + " Panel Light" - self._mac = coordinator.device.device_info.mac - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._name - - @property - def unique_id(self) -> str: - """Return a unique id for the device.""" - return f"{self._mac}-panel-light" + super().__init__(coordinator, "Panel Light") @property def icon(self) -> str | None: """Return the icon for the device.""" return "mdi:lightbulb" - @property - def device_info(self): - """Return device specific attributes.""" - return { - "name": self._name, - "identifiers": {(DOMAIN, self._mac)}, - "manufacturer": "Gree", - "connections": {(CONNECTION_NETWORK_MAC, self._mac)}, - } - @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" @@ -81,3 +65,93 @@ class GreeSwitchEntity(CoordinatorEntity, SwitchEntity): self.coordinator.device.light = False await self.coordinator.push_state_update() self.async_write_ha_state() + + +class GreeQuietModeSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the quiet mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Quiet") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.quiet + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.quiet = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.quiet = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + +class GreeFreshAirSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the fresh air mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "Fresh Air") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.fresh_air + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.fresh_air = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.fresh_air = False + await self.coordinator.push_state_update() + self.async_write_ha_state() + + +class GreeXFanSwitchEntity(GreeEntity, SwitchEntity): + """Representation of the extra fan mode state of the device.""" + + def __init__(self, coordinator): + """Initialize the Gree device.""" + super().__init__(coordinator, "XFan") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_SWITCH + + @property + def is_on(self) -> bool: + """Return if the state is turned on.""" + return self.coordinator.device.xfan + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self.coordinator.device.xfan = True + await self.coordinator.push_state_update() + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self.coordinator.device.xfan = False + await self.coordinator.push_state_update() + self.async_write_ha_state() diff --git a/homeassistant/components/gree/translations/ar.json b/homeassistant/components/gree/translations/ar.json new file mode 100644 index 00000000000..205a46af479 --- /dev/null +++ b/homeassistant/components/gree/translations/ar.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641 \u0627\u062c\u0647\u0632\u0629 \u0641\u064a \u0645\u0646\u0632\u0644\u0643", + "single_instance_allowed": "\u0633\u0628\u0642 \u0648\u062a\u0645 \u062a\u0643\u0648\u064a\u0646\u0647. \u0641\u0642\u0637 \u062a\u0643\u0648\u064a\u0646 \u0648\u0627\u062d\u062f \u0645\u0645\u0643\u0646." + }, + "step": { + "confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0628\u062f\u0621 \u0627\u0644\u0636\u0628\u0637\u061f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/gree/translations/de.json b/homeassistant/components/gree/translations/de.json index 86bc8e36730..19cd4b8c70e 100644 --- a/homeassistant/components/gree/translations/de.json +++ b/homeassistant/components/gree/translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 337a471eb2b..fac11395c8b 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -4,11 +4,12 @@ from homeassistant.const import ( CONF_NAME, CONF_SENSOR_TYPE, CONF_TEMPERATURE_UNIT, + DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_VOLT, POWER_WATT, TIME_HOURS, TIME_MINUTES, TIME_SECONDS, - VOLT, ) from . import ( @@ -240,6 +241,7 @@ class PulseCounter(GEMSensor): class TemperatureSensor(GEMSensor): """Entity showing temperature from one temperature sensor.""" + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_icon = TEMPERATURE_ICON def __init__(self, monitor_serial_number, number, name, unit): @@ -268,7 +270,7 @@ class VoltageSensor(GEMSensor): """Entity showing voltage.""" _attr_icon = VOLTAGE_ICON - _attr_unit_of_measurement = VOLT + _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT def __init__(self, monitor_serial_number, number, name): """Construct the entity.""" diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py index 41e4b99b6c6..b3d6898d984 100644 --- a/homeassistant/components/greenwave/light.py +++ b/homeassistant/components/greenwave/light.py @@ -35,7 +35,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): tokenfile = hass.config.path(".greenwave") if config.get(CONF_VERSION) == 3: if os.path.exists(tokenfile): - with open(tokenfile) as tokenfile: + with open(tokenfile, encoding="utf8") as tokenfile: token = tokenfile.read() else: try: @@ -43,7 +43,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except PermissionError: _LOGGER.error("The Gateway Is Not In Sync Mode") raise - with open(tokenfile, "w+") as tokenfile: + with open(tokenfile, "w+", encoding="utf8") as tokenfile: tokenfile.write(token) else: token = None diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index b5022582d9e..397c7e609f3 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -36,6 +36,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, + CONF_UNIQUE_ID, STATE_CLOSING, STATE_OPEN, STATE_OPENING, @@ -57,8 +58,9 @@ DEFAULT_NAME = "Cover Group" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -70,7 +72,13 @@ async def async_setup_platform( discovery_info: dict[str, Any] | None = None, ) -> None: """Set up the Group Cover platform.""" - async_add_entities([CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])]) + async_add_entities( + [ + CoverGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] + ) class CoverGroup(GroupEntity, CoverEntity): @@ -82,7 +90,7 @@ class CoverGroup(GroupEntity, CoverEntity): _attr_current_cover_position: int | None = 100 _attr_assumed_state: bool = True - def __init__(self, name: str, entities: list[str]) -> None: + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" self._entities = entities self._covers: dict[str, set[str]] = { @@ -98,6 +106,7 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} + self._attr_unique_id = unique_id async def _update_supported_features_event(self, event: Event) -> None: self.async_set_context(event.context) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 3f5a6eaf13e..bb0762d2278 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -39,6 +39,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, + CONF_UNIQUE_ID, STATE_ON, STATE_UNAVAILABLE, ) @@ -55,6 +56,7 @@ DEFAULT_NAME = "Light Group" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN), } ) @@ -72,7 +74,11 @@ async def async_setup_platform( ) -> None: """Initialize light.group platform.""" async_add_entities( - [LightGroup(cast(str, config.get(CONF_NAME)), config[CONF_ENTITIES])] + [ + LightGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] ) @@ -86,13 +92,14 @@ class LightGroup(GroupEntity, light.LightEntity): _attr_min_mireds = 154 _attr_should_poll = False - def __init__(self, name: str, entity_ids: list[str]) -> None: + def __init__(self, unique_id: str | None, name: str, entity_ids: list[str]) -> None: """Initialize a light group.""" self._entity_ids = entity_ids self._white_value: int | None = None self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 568812fd6e0..810959609b5 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -48,6 +48,7 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME, + CONF_UNIQUE_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, @@ -71,8 +72,9 @@ DEFAULT_NAME = "Media Group" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) @@ -84,17 +86,24 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Media Group platform.""" - async_add_entities([MediaGroup(config[CONF_NAME], config[CONF_ENTITIES])]) + async_add_entities( + [ + MediaGroup( + config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + ) + ] + ) class MediaGroup(MediaPlayerEntity): """Representation of a Media Group.""" - def __init__(self, name: str, entities: list[str]) -> None: + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a Media Group entity.""" self._name = name self._state: str | None = None self._supported_features: int = 0 + self._attr_unique_id = unique_id self._entities = entities self._features: dict[str, set[str]] = { diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index be7c2657e9f..0ca969e6812 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -7,7 +7,7 @@ "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea", "off": "\u05db\u05d1\u05d5\u05d9", "ok": "\u05ea\u05e7\u05d9\u05df", - "on": "\u05d3\u05dc\u05d5\u05e7", + "on": "\u05de\u05d5\u05e4\u05e2\u05dc", "open": "\u05e4\u05ea\u05d5\u05d7", "problem": "\u05d1\u05e2\u05d9\u05d4", "unlocked": "\u05e4\u05ea\u05d5\u05d7" diff --git a/homeassistant/components/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index cc1457d3687..45f56a327b2 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -3,10 +3,10 @@ import growattServer import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_PLANT_ID, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_URL, DOMAIN, SERVER_URLS class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -24,7 +24,11 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def _async_show_user_form(self, errors=None): """Show the form to the user.""" data_schema = vol.Schema( - {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_URL, default=DEFAULT_URL): vol.In(SERVER_URLS), + } ) return self.async_show_form( @@ -36,6 +40,7 @@ class GrowattServerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return self._async_show_user_form() + self.api.server_url = user_input[CONF_URL] login_response = await self.hass.async_add_executor_job( self.api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) diff --git a/homeassistant/components/growatt_server/const.py b/homeassistant/components/growatt_server/const.py index 4dc09988e6f..0b11e9994ca 100644 --- a/homeassistant/components/growatt_server/const.py +++ b/homeassistant/components/growatt_server/const.py @@ -5,6 +5,14 @@ DEFAULT_PLANT_ID = "0" DEFAULT_NAME = "Growatt" +SERVER_URLS = [ + "https://server.growatt.com/", + "https://server-us.growatt.com", + "http://server.smten.com/", +] + +DEFAULT_URL = SERVER_URLS[0] + DOMAIN = "growatt_server" PLATFORMS = ["sensor"] diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index c8921d9e514..fe6bdeb70e8 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, + CONF_URL, CONF_USERNAME, CURRENCY_EURO, DEVICE_CLASS_BATTERY, @@ -21,19 +22,19 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, PERCENTAGE, POWER_KILO_WATT, POWER_WATT, TEMP_CELSIUS, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle, dt -from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DOMAIN +from .const import CONF_PLANT_ID, DEFAULT_NAME, DEFAULT_PLANT_ID, DEFAULT_URL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -84,13 +85,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_1": ( "Input 1 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv1", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_1": ( "Input 1 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv1", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -102,13 +103,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_2": ( "Input 2 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv2", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_2": ( "Input 2 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv2", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -120,13 +121,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_voltage_input_3": ( "Input 3 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv3", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_amperage_input_3": ( "Input 3 Amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "ipv3", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -144,13 +145,13 @@ INVERTER_SENSOR_TYPES = { ), "inverter_reactive_voltage": ( "Reactive voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vacr", {"round": 1, "device_class": DEVICE_CLASS_VOLTAGE}, ), "inverter_inverter_reactive_amperage": ( "Reactive amperage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iacr", {"round": 1, "device_class": DEVICE_CLASS_CURRENT}, ), @@ -280,13 +281,13 @@ STORAGE_SENSOR_TYPES = { ), "storage_grid_voltage": ( "AC input voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vGrid", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), "storage_pv_charging_voltage": ( "PV charging voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -298,7 +299,7 @@ STORAGE_SENSOR_TYPES = { ), "storage_output_voltage": ( "Output voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "outPutVolt", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -310,31 +311,31 @@ STORAGE_SENSOR_TYPES = { ), "storage_current_PV": ( "Solar charge current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iAcCharge", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_current_1": ( "Solar current to storage", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "iChargePV1", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_grid_amperage_input": ( "Grid charge current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "chgCurr", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_grid_out_current": ( "Grid out current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "outPutCurrent", {"round": 2, "device_class": DEVICE_CLASS_CURRENT}, ), "storage_battery_voltage": ( "Battery voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vBat", {"round": 2, "device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -398,19 +399,19 @@ MIX_SENSOR_TYPES = { ), "mix_battery_voltage": ( "Battery voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vbat", {"device_class": DEVICE_CLASS_VOLTAGE}, ), "mix_pv1_voltage": ( "PV1 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv1", {"device_class": DEVICE_CLASS_VOLTAGE}, ), "mix_pv2_voltage": ( "PV2 voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vpv2", {"device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -490,7 +491,7 @@ MIX_SENSOR_TYPES = { ), "mix_grid_voltage": ( "Grid voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "vAc1", {"device_class": DEVICE_CLASS_VOLTAGE}, ), @@ -554,6 +555,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_URL, default=DEFAULT_URL): cv.string, } ) @@ -579,7 +581,7 @@ def get_device_list(api, config): # Log in to api and fetch first plant if no plant id is defined. login_response = api.login(config[CONF_USERNAME], config[CONF_PASSWORD]) if not login_response["success"] and login_response["errCode"] == "102": - _LOGGER.error("Username or Password may be incorrect!") + _LOGGER.error("Username, Password or URL may be incorrect!") return user_id = login_response["userId"] if plant_id == DEFAULT_PLANT_ID: @@ -596,9 +598,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): config = config_entry.data username = config[CONF_USERNAME] password = config[CONF_PASSWORD] + url = config.get(CONF_URL, DEFAULT_URL) name = config[CONF_NAME] api = growattServer.GrowattApi() + api.server_url = url devices, plant_id = await hass.async_add_executor_job(get_device_list, api, config) diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index e8d4f395c7b..45e25c0ba33 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -17,11 +17,12 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" + "username": "[%key:common::config_flow::data::username%]", + "url": "[%key:common::config_flow::data::url%]" }, "title": "Enter your Growatt information" } } }, "title": "Growatt Server" -} +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/ca.json b/homeassistant/components/growatt_server/translations/ca.json index 0c1e1b6cb83..39dc1153434 100644 --- a/homeassistant/components/growatt_server/translations/ca.json +++ b/homeassistant/components/growatt_server/translations/ca.json @@ -17,6 +17,7 @@ "data": { "name": "Nom", "password": "Contrasenya", + "url": "URL", "username": "Nom d'usuari" }, "title": "Introdueix la teva informaci\u00f3 de Growatt" diff --git a/homeassistant/components/growatt_server/translations/cs.json b/homeassistant/components/growatt_server/translations/cs.json new file mode 100644 index 00000000000..02c83a6e916 --- /dev/null +++ b/homeassistant/components/growatt_server/translations/cs.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/de.json b/homeassistant/components/growatt_server/translations/de.json index ae24396823a..adb769baa2d 100644 --- a/homeassistant/components/growatt_server/translations/de.json +++ b/homeassistant/components/growatt_server/translations/de.json @@ -17,6 +17,7 @@ "data": { "name": "Name", "password": "Passwort", + "url": "URL", "username": "Benutzername" }, "title": "Gib deine Growatt-Informationen ein" diff --git a/homeassistant/components/growatt_server/translations/en.json b/homeassistant/components/growatt_server/translations/en.json index 5461c822320..86196783133 100644 --- a/homeassistant/components/growatt_server/translations/en.json +++ b/homeassistant/components/growatt_server/translations/en.json @@ -17,6 +17,7 @@ "data": { "name": "Name", "password": "Password", + "url": "URL", "username": "Username" }, "title": "Enter your Growatt information" diff --git a/homeassistant/components/growatt_server/translations/et.json b/homeassistant/components/growatt_server/translations/et.json index 3115713bc68..c3327e3d676 100644 --- a/homeassistant/components/growatt_server/translations/et.json +++ b/homeassistant/components/growatt_server/translations/et.json @@ -17,6 +17,7 @@ "data": { "name": "Nimi", "password": "Salas\u00f5na", + "url": "URL", "username": "Kasutajanimi" }, "title": "Sisesta oma Growatti teave" diff --git a/homeassistant/components/growatt_server/translations/fr.json b/homeassistant/components/growatt_server/translations/fr.json new file mode 100644 index 00000000000..1ad47166f8d --- /dev/null +++ b/homeassistant/components/growatt_server/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "no_plants": "Aucune plante n'a \u00e9t\u00e9 trouv\u00e9e sur ce compte" + }, + "error": { + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "plant": { + "data": { + "plant_id": "Plante" + }, + "title": "S\u00e9lectionner votre plante" + }, + "user": { + "data": { + "name": "Nom", + "password": "Mot de passe", + "url": "URL", + "username": "Nom d'utilisateur" + }, + "title": "Entrer vos informations Growatt" + } + } + }, + "title": "Serveur Growatt" +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/he.json b/homeassistant/components/growatt_server/translations/he.json index cde5cec4fa4..8d430b5f4b2 100644 --- a/homeassistant/components/growatt_server/translations/he.json +++ b/homeassistant/components/growatt_server/translations/he.json @@ -4,10 +4,16 @@ "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" }, "step": { + "plant": { + "data": { + "plant_id": "\u05e6\u05de\u05d7" + } + }, "user": { "data": { "name": "\u05e9\u05dd", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } } diff --git a/homeassistant/components/growatt_server/translations/hu.json b/homeassistant/components/growatt_server/translations/hu.json index ff2c2fc87b5..d856d13a96b 100644 --- a/homeassistant/components/growatt_server/translations/hu.json +++ b/homeassistant/components/growatt_server/translations/hu.json @@ -1,11 +1,27 @@ { "config": { + "abort": { + "no_plants": "Ezen a sz\u00e1ml\u00e1n nem tal\u00e1ltak n\u00f6v\u00e9nyeket" + }, + "error": { + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, "step": { + "plant": { + "data": { + "plant_id": "N\u00f6v\u00e9ny" + }, + "title": "V\u00e1lassza ki a n\u00f6v\u00e9ny\u00e9t" + }, "user": { "data": { - "password": "Jelsz\u00f3" - } + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Adja meg Growatt adatait" } } - } + }, + "title": "Growatt szerver" } \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/id.json b/homeassistant/components/growatt_server/translations/id.json new file mode 100644 index 00000000000..789d4e1732b --- /dev/null +++ b/homeassistant/components/growatt_server/translations/id.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "name": "Nama", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/it.json b/homeassistant/components/growatt_server/translations/it.json index 19862f82d83..a3160c4164b 100644 --- a/homeassistant/components/growatt_server/translations/it.json +++ b/homeassistant/components/growatt_server/translations/it.json @@ -17,6 +17,7 @@ "data": { "name": "Nome", "password": "Password", + "url": "URL", "username": "Utente" }, "title": "Inserisci le tue informazioni Growatt" diff --git a/homeassistant/components/growatt_server/translations/nl.json b/homeassistant/components/growatt_server/translations/nl.json index 86b5a98b131..8d27f2b22af 100644 --- a/homeassistant/components/growatt_server/translations/nl.json +++ b/homeassistant/components/growatt_server/translations/nl.json @@ -17,6 +17,7 @@ "data": { "name": "Naam", "password": "Wachtwoord", + "url": "URL", "username": "Gebruikersnaam" }, "title": "Vul uw Growatt gegevens in" diff --git a/homeassistant/components/growatt_server/translations/pl.json b/homeassistant/components/growatt_server/translations/pl.json index 2041a577489..01e37307d7f 100644 --- a/homeassistant/components/growatt_server/translations/pl.json +++ b/homeassistant/components/growatt_server/translations/pl.json @@ -17,6 +17,7 @@ "data": { "name": "Nazwa", "password": "Has\u0142o", + "url": "URL", "username": "Nazwa u\u017cytkownika" }, "title": "Wprowad\u017a dane Growatt." diff --git a/homeassistant/components/growatt_server/translations/ru.json b/homeassistant/components/growatt_server/translations/ru.json index 02557515f97..0b98838dac8 100644 --- a/homeassistant/components/growatt_server/translations/ru.json +++ b/homeassistant/components/growatt_server/translations/ru.json @@ -17,11 +17,12 @@ "data": { "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "url": "URL-\u0430\u0434\u0440\u0435\u0441", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Growatt." } } }, - "title": "\u0421\u0435\u0440\u0432\u0435\u0440 Growatt" + "title": "Growatt" } \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/zh-Hant.json b/homeassistant/components/growatt_server/translations/zh-Hant.json index 4d00b4e8066..62991306cb8 100644 --- a/homeassistant/components/growatt_server/translations/zh-Hant.json +++ b/homeassistant/components/growatt_server/translations/zh-Hant.json @@ -17,6 +17,7 @@ "data": { "name": "\u540d\u7a31", "password": "\u5bc6\u78bc", + "url": "\u7db2\u5740", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, "title": "\u8f38\u5165 Growatt \u8cc7\u8a0a" diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 89e038b047e..915746c5ed5 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Awaitable +from typing import cast from aioguardian import Client @@ -9,7 +11,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_IP_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -25,6 +26,7 @@ from .const import ( CONF_UID, DATA_CLIENT, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_PAIRED_SENSOR_MANAGER, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, @@ -33,31 +35,26 @@ from .const import ( ) from .util import GuardianDataUpdateCoordinator -DATA_LAST_SENSOR_PAIR_DUMP = "last_sensor_pair_dump" - PLATFORMS = ["binary_sensor", "sensor", "switch"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the Elexa Guardian component.""" - hass.data[DOMAIN] = { - DATA_CLIENT: {}, - DATA_COORDINATOR: {}, - DATA_LAST_SENSOR_PAIR_DUMP: {}, - DATA_PAIRED_SENSOR_MANAGER: {}, - DATA_UNSUB_DISPATCHER_CONNECT: {}, - } - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Elexa Guardian from a config entry.""" + hass.data.setdefault( + DOMAIN, + { + DATA_CLIENT: {}, + DATA_COORDINATOR: {}, + DATA_COORDINATOR_PAIRED_SENSOR: {}, + DATA_PAIRED_SENSOR_MANAGER: {}, + DATA_UNSUB_DISPATCHER_CONNECT: {}, + }, + ) client = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] = Client( entry.data[CONF_IP_ADDRESS], port=entry.data[CONF_PORT] ) - hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = { - API_SENSOR_PAIRED_SENSOR_STATUS: {} - } + hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} + hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id] = {} hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id] = [] # The valve controller's UDP-based API can't handle concurrent requests very well, @@ -66,13 +63,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Set up DataUpdateCoordinators for the valve controller: init_valve_controller_tasks = [] - for api, api_coro in [ + for api, api_coro in ( (API_SENSOR_PAIR_DUMP, client.sensor.pair_dump), (API_SYSTEM_DIAGNOSTICS, client.system.diagnostics), (API_SYSTEM_ONBOARD_SENSOR_STATUS, client.system.onboard_sensor_status), (API_VALVE_STATUS, client.valve.status), (API_WIFI_STATUS, client.wifi.status), - ]: + ): coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ api ] = GuardianDataUpdateCoordinator( @@ -95,7 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await paired_sensor_manager.async_process_latest_paired_sensor_uids() @callback - def async_process_paired_sensor_uids(): + def async_process_paired_sensor_uids() -> None: """Define a callback for when new paired sensor data is received.""" hass.async_create_task( paired_sensor_manager.async_process_latest_paired_sensor_uids() @@ -117,7 +114,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) - hass.data[DOMAIN][DATA_LAST_SENSOR_PAIR_DUMP].pop(entry.entry_id) + hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR].pop(entry.entry_id) for unsub in hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT][entry.entry_id]: unsub() hass.data[DOMAIN][DATA_UNSUB_DISPATCHER_CONNECT].pop(entry.entry_id) @@ -140,8 +137,7 @@ class PairedSensorManager: self._client = client self._entry = entry self._hass = hass - self._listeners = [] - self._paired_uids = set() + self._paired_uids: set[str] = set() async def async_pair_sensor(self, uid: str) -> None: """Add a new paired sensor coordinator.""" @@ -149,13 +145,15 @@ class PairedSensorManager: self._paired_uids.add(uid) - coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + coordinator = self._hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + self._entry.entry_id ][uid] = GuardianDataUpdateCoordinator( self._hass, client=self._client, api_name=f"{API_SENSOR_PAIRED_SENSOR_STATUS}_{uid}", - api_coro=lambda: self._client.sensor.paired_sensor_status(uid), + api_coro=lambda: cast( + Awaitable, self._client.sensor.paired_sensor_status(uid) + ), api_lock=self._api_lock, valve_controller_uid=self._entry.data[CONF_UID], ) @@ -198,8 +196,8 @@ class PairedSensorManager: # Clear out objects related to this paired sensor: self._paired_uids.remove(uid) - self._hass.data[DOMAIN][DATA_COORDINATOR][self._entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + self._hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + self._entry.entry_id ].pop(uid) # Remove the paired sensor device from the device registry (which will @@ -215,52 +213,29 @@ class GuardianEntity(CoordinatorEntity): """Define a base Guardian entity.""" def __init__( # pylint: disable=super-init-not-called - self, entry: ConfigEntry, kind: str, name: str, device_class: str, icon: str + self, + entry: ConfigEntry, + kind: str, + name: str, + device_class: str | None, + icon: str | None, ) -> None: """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: "Data provided by Elexa"} - self._available = True + self._attr_device_class = device_class + self._attr_device_info = {"manufacturer": "Elexa"} + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: "Data provided by Elexa"} + self._attr_icon = icon + self._attr_name = name self._entry = entry - self._device_class = device_class - self._device_info = {"manufacturer": "Elexa"} - self._icon = icon - self._kind = kind - self._name = name - - @property - def device_class(self) -> str: - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return self._device_info - - @property - def extra_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attrs - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon @callback - def _async_update_from_latest_data(self): + def _async_update_from_latest_data(self) -> None: """Update the entity. This should be extended by Guardian platforms. """ raise NotImplementedError - @callback - def _async_update_state_callback(self): - """Update the entity's state.""" - self._async_update_from_latest_data() - self.async_write_ha_state() - class PairedSensorEntity(GuardianEntity): """Define a Guardian paired sensor entity.""" @@ -271,30 +246,23 @@ class PairedSensorEntity(GuardianEntity): coordinator: DataUpdateCoordinator, kind: str, name: str, - device_class: str, - icon: str, + device_class: str | None, + icon: str | None, ) -> None: """Initialize.""" super().__init__(entry, kind, name, device_class, icon) + paired_sensor_uid = coordinator.data["uid"] + self._attr_device_info = { + "identifiers": {(DOMAIN, paired_sensor_uid)}, + "name": f"Guardian Paired Sensor {paired_sensor_uid}", + "via_device": (DOMAIN, entry.data[CONF_UID]), + } + self._attr_name = f"Guardian Paired Sensor {paired_sensor_uid}: {name}" + self._attr_unique_id = f"{paired_sensor_uid}_{kind}" + self._kind = kind self.coordinator = coordinator - self._paired_sensor_uid = coordinator.data["uid"] - - self._device_info["identifiers"] = {(DOMAIN, self._paired_sensor_uid)} - self._device_info["name"] = f"Guardian Paired Sensor {self._paired_sensor_uid}" - self._device_info["via_device"] = (DOMAIN, self._entry.data[CONF_UID]) - - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"Guardian Paired Sensor {self._paired_sensor_uid}: {self._name}" - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"{self._paired_sensor_uid}_{self._kind}" - async def async_added_to_hass(self) -> None: """Perform tasks when the entity is added.""" self._async_update_from_latest_data() @@ -309,38 +277,31 @@ class ValveControllerEntity(GuardianEntity): coordinators: dict[str, DataUpdateCoordinator], kind: str, name: str, - device_class: str, - icon: str, + device_class: str | None, + icon: str | None, ) -> None: """Initialize.""" super().__init__(entry, kind, name, device_class, icon) + self._attr_device_info = { + "identifiers": {(DOMAIN, entry.data[CONF_UID])}, + "name": f"Guardian Valve Controller {entry.data[CONF_UID]}", + "model": coordinators[API_SYSTEM_DIAGNOSTICS].data["firmware"], + } + self._attr_name = f"Guardian {entry.data[CONF_UID]}: {name}" + self._attr_unique_id = f"{entry.data[CONF_UID]}_{kind}" + self._kind = kind self.coordinators = coordinators - self._device_info["identifiers"] = {(DOMAIN, self._entry.data[CONF_UID])} - self._device_info[ - "name" - ] = f"Guardian Valve Controller {self._entry.data[CONF_UID]}" - self._device_info["model"] = self.coordinators[API_SYSTEM_DIAGNOSTICS].data[ - "firmware" - ] - @property - def availabile(self) -> bool: + def available(self) -> bool: """Return if entity is available.""" - return any(coordinator.last_update_success for coordinator in self.coordinators) + return any( + coordinator.last_update_success + for coordinator in self.coordinators.values() + ) - @property - def name(self) -> str: - """Return the name of the entity.""" - return f"Guardian {self._entry.data[CONF_UID]}: {self._name}" - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"{self._entry.data[CONF_UID]}_{self._kind}" - - async def _async_continue_entity_setup(self): + async def _async_continue_entity_setup(self) -> None: """Perform additional, internal tasks when the entity is about to be added. This should be extended by Guardian platforms. @@ -350,9 +311,14 @@ class ValveControllerEntity(GuardianEntity): @callback def async_add_coordinator_update_listener(self, api: str) -> None: """Add a listener to a DataUpdateCoordinator based on the API referenced.""" - self.async_on_remove( - self.coordinators[api].async_add_listener(self._async_update_state_callback) - ) + + @callback + def update() -> None: + """Update the entity's state.""" + self._async_update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(self.coordinators[api].async_add_listener(update)) async def async_added_to_hass(self) -> None: """Perform tasks when the entity is added.""" @@ -365,12 +331,12 @@ class ValveControllerEntity(GuardianEntity): Only used by the generic entity update service. """ - # Ignore manual update requests if the entity is disabled if not self.enabled: return refresh_tasks = [ - coordinator.async_request_refresh() for coordinator in self.coordinators + coordinator.async_request_refresh() + for coordinator in self.coordinators.values() ] await asyncio.gather(*refresh_tasks) diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 869acc094d5..1cbc9f5cede 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -15,11 +15,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_ONBOARD_SENSOR_STATUS, API_WIFI_STATUS, CONF_UID, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, @@ -49,9 +49,9 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id][ + uid + ] entities = [] for kind in PAIRED_SENSOR_SENSORS: @@ -78,7 +78,7 @@ async def async_setup_entry( ) ) - sensors = [] + sensors: list[PairedSensorBinarySensor | ValveControllerBinarySensor] = [] # Add all valve controller-specific binary sensors: for kind in VALVE_CONTROLLER_SENSORS: @@ -95,8 +95,8 @@ async def async_setup_entry( ) # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id ].values(): for kind in PAIRED_SENSOR_SENSORS: name, device_class = SENSOR_ATTRS_MAP[kind] @@ -129,25 +129,15 @@ class PairedSensorBinarySensor(PairedSensorEntity, BinarySensorEntity): """Initialize.""" super().__init__(entry, coordinator, kind, name, device_class, icon) - self._is_on = True - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinator.last_update_success - - @property - def is_on(self) -> bool: - """Return True if the binary sensor is on.""" - return self._is_on + self._attr_is_on = True @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_LEAK_DETECTED: - self._is_on = self.coordinator.data["wet"] + self._attr_is_on = self.coordinator.data["wet"] elif self._kind == SENSOR_KIND_MOVED: - self._is_on = self.coordinator.data["moved"] + self._attr_is_on = self.coordinator.data["moved"] class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): @@ -165,23 +155,7 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): """Initialize.""" super().__init__(entry, coordinators, kind, name, device_class, icon) - self._is_on = True - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - if self._kind == SENSOR_KIND_AP_INFO: - return self.coordinators[API_WIFI_STATUS].last_update_success - if self._kind == SENSOR_KIND_LEAK_DETECTED: - return self.coordinators[ - API_SYSTEM_ONBOARD_SENSOR_STATUS - ].last_update_success - return False - - @property - def is_on(self) -> bool: - """Return True if the binary sensor is on.""" - return self._is_on + self._attr_is_on = True async def _async_continue_entity_setup(self) -> None: """Add an API listener.""" @@ -194,8 +168,13 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_AP_INFO: - self._is_on = self.coordinators[API_WIFI_STATUS].data["station_connected"] - self._attrs.update( + self._attr_available = self.coordinators[ + API_WIFI_STATUS + ].last_update_success + self._attr_is_on = self.coordinators[API_WIFI_STATUS].data[ + "station_connected" + ] + self._attr_extra_state_attributes.update( { ATTR_CONNECTED_CLIENTS: self.coordinators[API_WIFI_STATUS].data.get( "ap_clients" @@ -203,6 +182,9 @@ class ValveControllerBinarySensor(ValveControllerEntity, BinarySensorEntity): } ) elif self._kind == SENSOR_KIND_LEAK_DETECTED: - self._is_on = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ + self._attr_available = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].last_update_success + self._attr_is_on = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ "wet" ] diff --git a/homeassistant/components/guardian/config_flow.py b/homeassistant/components/guardian/config_flow.py index 05be79da344..ccebeb99675 100644 --- a/homeassistant/components/guardian/config_flow.py +++ b/homeassistant/components/guardian/config_flow.py @@ -1,12 +1,18 @@ """Config flow for Elexa Guardian integration.""" +from __future__ import annotations + +from typing import Any + from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries from homeassistant.components.dhcp import IP_ADDRESS from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_UID, DOMAIN, LOGGER @@ -23,18 +29,18 @@ UNIQUE_ID = "guardian_{0}" @callback -def async_get_pin_from_discovery_hostname(hostname): +def async_get_pin_from_discovery_hostname(hostname: str) -> str: """Get the device's 4-digit PIN from its zeroconf-discovered hostname.""" return hostname.split(".")[0].split("-")[1] @callback -def async_get_pin_from_uid(uid): +def async_get_pin_from_uid(uid: str) -> str: """Get the device's 4-digit PIN from its UID.""" return uid[-4:] -async def validate_input(hass: core.HomeAssistant, data): +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -52,11 +58,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.discovery_info = {} + self.discovery_info: dict[str, Any] = {} - async def _async_set_unique_id(self, pin): + async def _async_set_unique_id(self, pin: str) -> None: """Set the config entry's unique ID (based on the device's 4-digit PIN).""" await self.async_set_unique_id(UNIQUE_ID.format(pin)) if self.discovery_info: @@ -66,7 +72,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): else: self._abort_if_unique_id_configured() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle configuration via the UI.""" if user_input is None: return self.async_show_form( @@ -90,7 +98,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=info[CONF_UID], data={CONF_UID: info["uid"], **user_input} ) - async def async_step_dhcp(self, discovery_info): + async def async_step_dhcp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle the configuration via dhcp.""" self.discovery_info = { CONF_IP_ADDRESS: discovery_info[IP_ADDRESS], @@ -98,7 +106,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } return await self._async_handle_discovery() - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle the configuration via zeroconf.""" self.discovery_info = { CONF_IP_ADDRESS: discovery_info["host"], @@ -108,7 +118,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self._async_set_unique_id(pin) return await self._async_handle_discovery() - async def _async_handle_discovery(self): + async def _async_handle_discovery(self) -> FlowResult: """Handle any discovery.""" self.context[CONF_IP_ADDRESS] = self.discovery_info[CONF_IP_ADDRESS] if any( @@ -119,7 +129,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() - async def async_step_discovery_confirm(self, user_input=None): + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Finish the configuration via any discovery.""" if user_input is None: self._set_confirm_only() diff --git a/homeassistant/components/guardian/const.py b/homeassistant/components/guardian/const.py index 750a8c407ca..e27e8a37047 100644 --- a/homeassistant/components/guardian/const.py +++ b/homeassistant/components/guardian/const.py @@ -16,6 +16,7 @@ CONF_UID = "uid" DATA_CLIENT = "client" DATA_COORDINATOR = "coordinator" +DATA_COORDINATOR_PAIRED_SENSOR = "coordinator_paired_sensor" DATA_PAIRED_SENSOR_MANAGER = "paired_sensor_manager" DATA_UNSUB_DISPATCHER_CONNECT = "unsub_dispatcher_connect" diff --git a/homeassistant/components/guardian/manifest.json b/homeassistant/components/guardian/manifest.json index 60411c5292b..baa7eb50e7a 100644 --- a/homeassistant/components/guardian/manifest.json +++ b/homeassistant/components/guardian/manifest.json @@ -3,7 +3,7 @@ "name": "Elexa Guardian", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/guardian", - "requirements": ["aioguardian==1.0.4"], + "requirements": ["aioguardian==1.0.8"], "zeroconf": ["_api._udp.local."], "codeowners": ["@bachya"], "iot_class": "local_polling", diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py index 2d62fe2c613..2d7cde86cca 100644 --- a/homeassistant/components/guardian/sensor.py +++ b/homeassistant/components/guardian/sensor.py @@ -17,11 +17,11 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import PairedSensorEntity, ValveControllerEntity from .const import ( - API_SENSOR_PAIRED_SENSOR_STATUS, API_SYSTEM_DIAGNOSTICS, API_SYSTEM_ONBOARD_SENSOR_STATUS, CONF_UID, DATA_COORDINATOR, + DATA_COORDINATOR_PAIRED_SENSOR, DATA_UNSUB_DISPATCHER_CONNECT, DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, @@ -54,9 +54,9 @@ async def async_setup_entry( @callback def add_new_paired_sensor(uid: str) -> None: """Add a new paired sensor.""" - coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS - ][uid] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][entry.entry_id][ + uid + ] entities = [] for kind in PAIRED_SENSOR_SENSORS: @@ -78,7 +78,7 @@ async def async_setup_entry( ) ) - sensors = [] + sensors: list[PairedSensorSensor | ValveControllerSensor] = [] # Add all valve controller-specific binary sensors: for kind in VALVE_CONTROLLER_SENSORS: @@ -96,8 +96,8 @@ async def async_setup_entry( ) # Add all paired sensor-specific binary sensors: - for coordinator in hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ - API_SENSOR_PAIRED_SENSOR_STATUS + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR_PAIRED_SENSOR][ + entry.entry_id ].values(): for kind in PAIRED_SENSOR_SENSORS: name, device_class, icon, unit = SENSOR_ATTRS_MAP[kind] @@ -126,31 +126,15 @@ class PairedSensorSensor(PairedSensorEntity, SensorEntity): """Initialize.""" super().__init__(entry, coordinator, kind, name, device_class, icon) - self._state = None - self._unit = unit - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinator.last_update_success - - @property - def state(self) -> str: - """Return the sensor state.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit + self._attr_unit_of_measurement = unit @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_BATTERY: - self._state = self.coordinator.data["battery"] + self._attr_state = self.coordinator.data["battery"] elif self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinator.data["temperature"] + self._attr_state = self.coordinator.data["temperature"] class ValveControllerSensor(ValveControllerEntity, SensorEntity): @@ -169,29 +153,7 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): """Initialize.""" super().__init__(entry, coordinators, kind, name, device_class, icon) - self._state = None - self._unit = unit - - @property - def available(self) -> bool: - """Return whether the entity is available.""" - if self._kind == SENSOR_KIND_TEMPERATURE: - return self.coordinators[ - API_SYSTEM_ONBOARD_SENSOR_STATUS - ].last_update_success - if self._kind == SENSOR_KIND_UPTIME: - return self.coordinators[API_SYSTEM_DIAGNOSTICS].last_update_success - return False - - @property - def state(self) -> str: - """Return the sensor state.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return self._unit + self._attr_unit_of_measurement = unit async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" @@ -202,8 +164,14 @@ class ValveControllerSensor(ValveControllerEntity, SensorEntity): def _async_update_from_latest_data(self) -> None: """Update the entity.""" if self._kind == SENSOR_KIND_TEMPERATURE: - self._state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ + self._attr_available = self.coordinators[ + API_SYSTEM_ONBOARD_SENSOR_STATUS + ].last_update_success + self._attr_state = self.coordinators[API_SYSTEM_ONBOARD_SENSOR_STATUS].data[ "temperature" ] elif self._kind == SENSOR_KIND_UPTIME: - self._state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] + self._attr_available = self.coordinators[ + API_SYSTEM_DIAGNOSTICS + ].last_update_success + self._attr_state = self.coordinators[API_SYSTEM_DIAGNOSTICS].data["uptime"] diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index fe39ee635f4..f3621a72952 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,6 +1,8 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations +from typing import Any + from aioguardian import Client from aioguardian.errors import GuardianError import voluptuous as vol @@ -44,7 +46,7 @@ async def async_setup_entry( """Set up Guardian switches based on a config entry.""" platform = entity_platform.async_get_current_platform() - for service_name, schema, method in [ + for service_name, schema, method in ( (SERVICE_DISABLE_AP, {}, "async_disable_ap"), (SERVICE_ENABLE_AP, {}, "async_enable_ap"), (SERVICE_PAIR_SENSOR, {vol.Required(CONF_UID): cv.string}, "async_pair_sensor"), @@ -64,7 +66,7 @@ async def async_setup_entry( {vol.Required(CONF_UID): cv.string}, "async_unpair_sensor", ), - ]: + ): platform.async_register_entity_service(service_name, schema, method) async_add_entities( @@ -92,34 +94,25 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): entry, coordinators, "valve", "Valve Controller", None, "mdi:water" ) + self._attr_is_on = True self._client = client - self._is_on = True - @property - def available(self) -> bool: - """Return whether the entity is available.""" - return self.coordinators[API_VALVE_STATUS].last_update_success - - @property - def is_on(self) -> bool: - """Return True if the valve is open.""" - return self._is_on - - async def _async_continue_entity_setup(self): + async def _async_continue_entity_setup(self) -> None: """Register API interest (and related tasks) when the entity is added.""" self.async_add_coordinator_update_listener(API_VALVE_STATUS) @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - self._is_on = self.coordinators[API_VALVE_STATUS].data["state"] in ( + self._attr_available = self.coordinators[API_VALVE_STATUS].last_update_success + self._attr_is_on = self.coordinators[API_VALVE_STATUS].data["state"] in ( "start_opening", "opening", "finish_opening", "opened", ) - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_AVG_CURRENT: self.coordinators[API_VALVE_STATUS].data[ "average_current" @@ -136,7 +129,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): } ) - async def async_disable_ap(self): + async def async_disable_ap(self) -> None: """Disable the device's onboard access point.""" try: async with self._client: @@ -144,7 +137,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while disabling valve controller AP: %s", err) - async def async_enable_ap(self): + async def async_enable_ap(self) -> None: """Enable the device's onboard access point.""" try: async with self._client: @@ -152,7 +145,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while enabling valve controller AP: %s", err) - async def async_pair_sensor(self, *, uid): + async def async_pair_sensor(self, *, uid: str) -> None: """Add a new paired sensor.""" try: async with self._client: @@ -165,7 +158,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._entry.entry_id ].async_pair_sensor(uid) - async def async_reboot(self): + async def async_reboot(self) -> None: """Reboot the device.""" try: async with self._client: @@ -173,7 +166,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while rebooting valve controller: %s", err) - async def async_reset_valve_diagnostics(self): + async def async_reset_valve_diagnostics(self) -> None: """Fully reset system motor diagnostics.""" try: async with self._client: @@ -181,7 +174,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while resetting valve diagnostics: %s", err) - async def async_unpair_sensor(self, *, uid): + async def async_unpair_sensor(self, *, uid: str) -> None: """Add a new paired sensor.""" try: async with self._client: @@ -194,7 +187,9 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): self._entry.entry_id ].async_unpair_sensor(uid) - async def async_upgrade_firmware(self, *, url, port, filename): + async def async_upgrade_firmware( + self, *, url: str, port: int, filename: str + ) -> None: """Upgrade the device firmware.""" try: async with self._client: @@ -206,7 +201,7 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): except GuardianError as err: LOGGER.error("Error while upgrading firmware: %s", err) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: """Turn the valve off (closed).""" try: async with self._client: @@ -215,10 +210,10 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): LOGGER.error("Error while closing the valve: %s", err) return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: """Turn the valve on (open).""" try: async with self._client: @@ -227,5 +222,5 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): LOGGER.error("Error while opening the valve: %s", err) return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/guardian/translations/de.json b/homeassistant/components/guardian/translations/de.json index fc3ca8fee06..63949b22de6 100644 --- a/homeassistant/components/guardian/translations/de.json +++ b/homeassistant/components/guardian/translations/de.json @@ -7,7 +7,7 @@ }, "step": { "discovery_confirm": { - "description": "M\u00f6chten Sie dieses Guardian-Ger\u00e4t einrichten?" + "description": "M\u00f6chtest du dieses Guardian-Ger\u00e4t einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/guardian/translations/fr.json b/homeassistant/components/guardian/translations/fr.json index ca5635a17b7..62ffae35776 100644 --- a/homeassistant/components/guardian/translations/fr.json +++ b/homeassistant/components/guardian/translations/fr.json @@ -6,6 +6,9 @@ "cannot_connect": "\u00c9chec de connexion" }, "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer cet appareil Guardian\u00a0?" + }, "user": { "data": { "ip_address": "Adresse IP", diff --git a/homeassistant/components/guardian/translations/hu.json b/homeassistant/components/guardian/translations/hu.json index bd43ce7672c..ca9a746f9d9 100644 --- a/homeassistant/components/guardian/translations/hu.json +++ b/homeassistant/components/guardian/translations/hu.json @@ -6,6 +6,9 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "step": { + "discovery_confirm": { + "description": "Be akarja \u00e1ll\u00edtani ezt a Guardian eszk\u00f6zt?" + }, "user": { "data": { "ip_address": "IP c\u00edm", diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index beaf71dea51..c4d0e0be4d7 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable from datetime import timedelta -from typing import Callable +from typing import Any, Callable, Dict, cast from aioguardian import Client from aioguardian.errors import GuardianError @@ -42,11 +42,11 @@ class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): self._api_lock = api_lock self._client = client - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Execute a "locked" API request against the valve controller.""" async with self._api_lock, self._client: try: resp = await self._api_coro() except GuardianError as err: raise UpdateFailed(err) from err - return resp["data"] + return cast(Dict[str, Any], resp["data"]) diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 868d024b02e..d25b840d761 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -1,20 +1,19 @@ { - "config": { - "error": { - "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "url": "[%key:common::config_flow::data::url%]", - "name": "Override for Habitica’s username. Will be used for service calls", - "api_user": "Habitica’s API user ID", - "api_key": "[%key:common::config_flow::data::api_key%]" - }, - "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" - } - } + "config": { + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, - "title": "Habitica" + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + } } diff --git a/homeassistant/components/habitica/translations/de.json b/homeassistant/components/habitica/translations/de.json index ad4f3d2aff8..694bbdd65d4 100644 --- a/homeassistant/components/habitica/translations/de.json +++ b/homeassistant/components/habitica/translations/de.json @@ -12,7 +12,7 @@ "name": "Override f\u00fcr den Benutzernamen von Habitica. Wird f\u00fcr Serviceaufrufe verwendet", "url": "URL" }, - "description": "Verbinden Sie Ihr Habitica-Profil, um die \u00dcberwachung des Profils und der Aufgaben Ihres Benutzers zu erm\u00f6glichen. Beachten Sie, dass api_id und api_key von https://habitica.com/user/settings/api bezogen werden m\u00fcssen." + "description": "Verbinde dein Habitica-Profil, um die \u00dcberwachung des Profils und der Aufgaben deines Benutzers zu erm\u00f6glichen. Beachte, dass api_id und api_key von https://habitica.com/user/settings/api bezogen werden m\u00fcssen." } } }, diff --git a/homeassistant/components/habitica/translations/hu.json b/homeassistant/components/habitica/translations/hu.json index 4914a1bd27a..589f53e852c 100644 --- a/homeassistant/components/habitica/translations/hu.json +++ b/homeassistant/components/habitica/translations/hu.json @@ -9,8 +9,10 @@ "data": { "api_key": "API kulcs", "api_user": "Habitica API felhaszn\u00e1l\u00f3i azonos\u00edt\u00f3ja", + "name": "A Habitica felhaszn\u00e1l\u00f3n\u00e9v fel\u00fcl\u00edr\u00e1sa. A szolg\u00e1ltat\u00e1si h\u00edv\u00e1sokhoz lesz haszn\u00e1lva", "url": "URL" - } + }, + "description": "Csatlakoztassa Habitica-profilj\u00e1t, hogy figyelemmel k\u00eds\u00e9rhesse felhaszn\u00e1l\u00f3i profilj\u00e1t \u00e9s feladatait. Ne feledje, hogy az api_id \u00e9s api_key c\u00edmeket a https://habitica.com/user/settings/api webhelyr\u0151l kell beszerezni" } } }, diff --git a/homeassistant/components/hangouts/translations/de.json b/homeassistant/components/hangouts/translations/de.json index 7b888cf531e..42770308346 100644 --- a/homeassistant/components/hangouts/translations/de.json +++ b/homeassistant/components/hangouts/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Google Hangouts ist bereits konfiguriert", + "already_configured": "Der Dienst ist bereits konfiguriert", "unknown": "Unerwarteter Fehler" }, "error": { @@ -20,7 +20,7 @@ "user": { "data": { "authorization_code": "Autorisierungscode (f\u00fcr die manuelle Authentifizierung erforderlich)", - "email": "E-Mail-Adresse", + "email": "E-Mail", "password": "Passwort" }, "description": "Leer", diff --git a/homeassistant/components/hangouts/translations/hu.json b/homeassistant/components/hangouts/translations/hu.json index b81e3fcf0dd..3c065b01169 100644 --- a/homeassistant/components/hangouts/translations/hu.json +++ b/homeassistant/components/hangouts/translations/hu.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA Pin" }, + "description": "\u00dcres", "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s" }, "user": { @@ -21,6 +22,7 @@ "email": "E-mail", "password": "Jelsz\u00f3" }, + "description": "\u00dcres", "title": "Google Hangouts Bejelentkez\u00e9s" } } diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py index e76e5559f9d..c541aa0e0e3 100644 --- a/homeassistant/components/harmony/__init__.py +++ b/homeassistant/components/harmony/__init__.py @@ -94,7 +94,7 @@ async def _migrate_old_unique_ids( def _async_import_options_from_data_if_missing(hass: HomeAssistant, entry: ConfigEntry): options = dict(entry.options) modified = 0 - for importable_option in [ATTR_ACTIVITY, ATTR_DELAY_SECS]: + for importable_option in (ATTR_ACTIVITY, ATTR_DELAY_SECS): if importable_option not in entry.options and importable_option in entry.data: options[importable_option] = entry.data[importable_option] modified = 1 diff --git a/homeassistant/components/harmony/translations/de.json b/homeassistant/components/harmony/translations/de.json index 5083ccd848f..24cdc51cd65 100644 --- a/homeassistant/components/harmony/translations/de.json +++ b/homeassistant/components/harmony/translations/de.json @@ -10,15 +10,15 @@ "flow_title": "{name}", "step": { "link": { - "description": "M\u00f6chten Sie {name} ({host}) einrichten?", - "title": "Richten Sie den Logitech Harmony Hub ein" + "description": "M\u00f6chtest du {name} ({host}) einrichten?", + "title": "Richte den Logitech Harmony Hub ein" }, "user": { "data": { "host": "Host", "name": "Hub-Name" }, - "title": "Richten Sie den Logitech Harmony Hub ein" + "title": "Richte den Logitech Harmony Hub ein" } } }, @@ -29,7 +29,7 @@ "activity": "Die Standardaktivit\u00e4t, die ausgef\u00fchrt werden soll, wenn keine angegeben ist.", "delay_secs": "Die Verz\u00f6gerung zwischen dem Senden von Befehlen." }, - "description": "Passen Sie die Harmony Hub-Optionen an" + "description": "Passe die Harmony Hub-Optionen an" } } } diff --git a/homeassistant/components/harmony/translations/he.json b/homeassistant/components/harmony/translations/he.json index 1331c17e961..49470b50ca9 100644 --- a/homeassistant/components/harmony/translations/he.json +++ b/homeassistant/components/harmony/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "link": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index b0c6e9d1dbe..e58c2d790f2 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -8,7 +8,7 @@ import os import aiohttp from aiohttp import ClientTimeout, hdrs, web -from aiohttp.web_exceptions import HTTPBadGateway +from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from homeassistant.components.http import HomeAssistantView @@ -185,7 +185,11 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st # Set X-Forwarded-For forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) - connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) + if (peername := request.transport.get_extra_info("peername")) is None: + _LOGGER.error("Can't set forward_for header, missing peername") + raise HTTPBadRequest() + + connected_ip = ip_address(peername[0]) if forward_for: forward_for = f"{forward_for}, {connected_ip!s}" else: diff --git a/homeassistant/components/hassio/translations/hu.json b/homeassistant/components/hassio/translations/hu.json index ae7a51a8ec8..64b0f26ae46 100644 --- a/homeassistant/components/hassio/translations/hu.json +++ b/homeassistant/components/hassio/translations/hu.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "board": "Alaplap", "disk_total": "\u00d6sszes hely", "disk_used": "Felhaszn\u00e1lt hely", "docker_version": "Docker verzi\u00f3", diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 4376c7f1289..8169fa811e0 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -81,6 +82,11 @@ class HddTempSensor(SensorEntity): """Return the state of the device.""" return self._state + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_TEMPERATURE + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index c02456b2a3f..11fd19bd895 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -27,7 +27,7 @@ from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import DiscoveryInfoType -import homeassistant.util.dt as dt +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hisense_aehw4a1/translations/de.json b/homeassistant/components/hisense_aehw4a1/translations/de.json index 7c0bd96a9c9..03e15051eb0 100644 --- a/homeassistant/components/hisense_aehw4a1/translations/de.json +++ b/homeassistant/components/hisense_aehw4a1/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine Hisense AEH-W4A1-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 8fa9fe879f5..3651dd8295f 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -164,7 +164,7 @@ async def ws_get_statistics_during_period( @websocket_api.websocket_command( { vol.Required("type"): "history/list_statistic_ids", - vol.Optional("statistic_type"): str, + vol.Optional("statistic_type"): vol.Any("sum", "mean"), } ) @websocket_api.require_admin diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 69f42da5e36..e8ff9afc4e3 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -86,7 +86,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= sensor_type = config.get(CONF_TYPE) name = config.get(CONF_NAME) - for template in [start, end]: + for template in (start, end): if template is not None: template.hass = hass diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index 80c6a7e40f1..ce07abcb338 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -31,6 +31,7 @@ "user": { "data": { "password": "Jelsz\u00f3", + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Add meg a Hive bejelentkez\u00e9si adatait \u00e9s konfigur\u00e1ci\u00f3j\u00e1t.", @@ -41,6 +42,10 @@ "options": { "step": { "user": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "Friss\u00edtse a vizsg\u00e1lati intervallumot az adatok gyakrabban t\u00f6rt\u00e9n\u0151 lek\u00e9rdez\u00e9s\u00e9hez.", "title": "Hive be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 718900533aa..e775b9d97aa 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -140,10 +140,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def start_platforms(): """Continue setting up the platforms.""" await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) # Only refresh the coordinator after all platforms are loaded. await coordinator.async_refresh() diff --git a/homeassistant/components/home_plus_control/strings.json b/homeassistant/components/home_plus_control/strings.json index c991c9e0279..9e860b397fb 100644 --- a/homeassistant/components/home_plus_control/strings.json +++ b/homeassistant/components/home_plus_control/strings.json @@ -1,5 +1,4 @@ { - "title": "Legrand Home+ Control", "config": { "step": { "pick_implementation": { diff --git a/homeassistant/components/home_plus_control/translations/de.json b/homeassistant/components/home_plus_control/translations/de.json index 8e7d9e9bc24..8cb47ae3fec 100644 --- a/homeassistant/components/home_plus_control/translations/de.json +++ b/homeassistant/components/home_plus_control/translations/de.json @@ -17,5 +17,5 @@ } } }, - "title": "" + "title": "Legrand Home+ Steuerung" } \ No newline at end of file diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 44f0843871c..e798fda209b 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -248,10 +248,10 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> bool: # noqa: C9 if not reload_entries: raise ValueError("There were no matching config entries to reload") await asyncio.gather( - *[ + *( hass.config_entries.async_reload(config_entry_id) for config_entry_id in reload_entries - ] + ) ) hass.helpers.service.async_register_admin_service( diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 13a4ef66383..9ae271baa72 100644 --- a/homeassistant/components/homeassistant/scene.py +++ b/homeassistant/components/homeassistant/scene.py @@ -1,9 +1,8 @@ """Allow users to set and activate scenes.""" from __future__ import annotations -from collections import namedtuple import logging -from typing import Any +from typing import Any, NamedTuple import voluptuous as vol @@ -115,10 +114,19 @@ CREATE_SCENE_SCHEMA = vol.All( SERVICE_APPLY = "apply" SERVICE_CREATE = "create" -SCENECONFIG = namedtuple("SceneConfig", [CONF_ID, CONF_NAME, CONF_ICON, STATES]) + _LOGGER = logging.getLogger(__name__) +class SceneConfig(NamedTuple): + """Object for storing scene config.""" + + id: str + name: str + icon: str + states: dict + + @callback def scenes_with_entity(hass: HomeAssistant, entity_id: str) -> list[str]: """Return all scenes that reference the entity.""" @@ -238,7 +246,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= _LOGGER.warning("Empty scenes are not allowed") return - scene_config = SCENECONFIG(None, call.data[CONF_SCENE_ID], None, entities) + scene_config = SceneConfig(None, call.data[CONF_SCENE_ID], None, entities) entity_id = f"{SCENE_DOMAIN}.{scene_config.name}" old = platform.entities.get(entity_id) if old is not None: @@ -264,7 +272,7 @@ def _process_scenes_config(hass, async_add_entities, config): async_add_entities( HomeAssistantScene( hass, - SCENECONFIG( + SceneConfig( scene.get(CONF_ID), scene[CONF_NAME], scene.get(CONF_ICON), diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7da4a5a9d8a..09be9283b5c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -4,6 +4,7 @@ "arch": "CPU Architecture", "dev": "Development", "docker": "Docker", + "user": "User", "hassio": "Supervisor", "installation_type": "Installation Type", "os_name": "Operating System Family", @@ -14,4 +15,4 @@ "virtualenv": "Virtual Environment" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index ff3562a24f9..f13278ddfeb 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -22,6 +22,7 @@ async def system_health_info(hass): "dev": info.get("dev"), "hassio": info.get("hassio"), "docker": info.get("docker"), + "user": info.get("user"), "virtualenv": info.get("virtualenv"), "python_version": info.get("python_version"), "os_name": info.get("os_name"), diff --git a/homeassistant/components/homeassistant/triggers/numeric_state.py b/homeassistant/components/homeassistant/triggers/numeric_state.py index 366f937a192..f315addb272 100644 --- a/homeassistant/components/homeassistant/triggers/numeric_state.py +++ b/homeassistant/components/homeassistant/triggers/numeric_state.py @@ -79,7 +79,7 @@ async def async_attach_trigger( job = HassJob(action) trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables = {} + _variables: dict = {} if automation_info: _variables = automation_info.get("variables") or {} diff --git a/homeassistant/components/homeassistant/triggers/state.py b/homeassistant/components/homeassistant/triggers/state.py index 2c96b6be944..12c42a95978 100644 --- a/homeassistant/components/homeassistant/triggers/state.py +++ b/homeassistant/components/homeassistant/triggers/state.py @@ -88,7 +88,7 @@ async def async_attach_trigger( job = HassJob(action) trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} - _variables = {} + _variables: dict = {} if automation_info: _variables = automation_info.get("variables") or {} @@ -171,10 +171,11 @@ async def async_attach_trigger( ) return - def _check_same_state(_, _2, new_st: State): + def _check_same_state(_, _2, new_st: State | None) -> bool: if new_st is None: return False + cur_value: str | None if attribute is None: cur_value = new_st.state else: diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index ff78e4c43c8..f661ae21a5b 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -25,7 +25,7 @@ import homeassistant.util.dt as dt_util _TIME_TRIGGER_SCHEMA = vol.Any( cv.time, - vol.All(str, cv.entity_domain(("input_datetime", "sensor"))), + vol.All(str, cv.entity_domain(["input_datetime", "sensor"])), msg="Expected HH:MM, HH:MM:SS or Entity ID with domain 'input_datetime' or 'sensor'", ) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c0cc5867799..f85c8ad5063 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,7 +8,7 @@ from aiohttp import web from pyhap.const import STANDALONE_AID import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import network, zeroconf from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_MOTION, @@ -23,6 +23,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_IP_ADDRESS, CONF_NAME, @@ -34,13 +35,13 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry, entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import BASE_FILTER_SCHEMA, FILTER_SCHEMA from homeassistant.helpers.reload import async_integration_yaml_config +from homeassistant.helpers.service import async_extract_referenced_entity_ids from homeassistant.loader import IntegrationNotFound, async_get_integration -from homeassistant.util import get_local_ip from . import ( # noqa: F401 type_cameras, @@ -94,6 +95,7 @@ from .const import ( MANUFACTURER, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) from .util import ( @@ -119,6 +121,12 @@ STATUS_WAIT = 3 PORT_CLEANUP_CHECK_INTERVAL_SECS = 1 +MDNS_TARGET_IP = "224.0.0.251" + +_HOMEKIT_CONFIG_UPDATE_TIME = ( + 5 # number of seconds to wait for homekit to see the c# change +) + def _has_all_unique_names_and_ports(bridges): """Validate that each homekit bridge configured has a unique name.""" @@ -165,6 +173,12 @@ RESET_ACCESSORY_SERVICE_SCHEMA = vol.Schema( ) +UNPAIR_SERVICE_SCHEMA = vol.All( + vol.Schema(cv.ENTITY_SERVICE_FIELDS), + cv.has_at_least_one_key(ATTR_DEVICE_ID), +) + + def _async_get_entries_by_name(current_entries): """Return a dict of the entries by name.""" @@ -243,7 +257,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Begin setup HomeKit for %s", name) # ip_address and advertise_ip are yaml only - ip_address = conf.get(CONF_IP_ADDRESS) + ip_address = conf.get( + CONF_IP_ADDRESS, await network.async_get_source_ip(hass, MDNS_TARGET_IP) + ) advertise_ip = conf.get(CONF_ADVERTISE_IP) # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after @@ -348,8 +364,8 @@ def _async_register_events_and_services(hass: HomeAssistant): """Register events and services for HomeKit.""" hass.http.register_view(HomeKitPairingQRView) - def handle_homekit_reset_accessory(service): - """Handle start HomeKit service call.""" + async def async_handle_homekit_reset_accessory(service): + """Handle reset accessory HomeKit service call.""" for entry_id in hass.data[DOMAIN]: if HOMEKIT not in hass.data[DOMAIN][entry_id]: continue @@ -362,15 +378,53 @@ def _async_register_events_and_services(hass: HomeAssistant): continue entity_ids = service.data.get("entity_id") - homekit.reset_accessories(entity_ids) + await homekit.async_reset_accessories(entity_ids) hass.services.async_register( DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY, - handle_homekit_reset_accessory, + async_handle_homekit_reset_accessory, schema=RESET_ACCESSORY_SERVICE_SCHEMA, ) + async def async_handle_homekit_unpair(service): + """Handle unpair HomeKit service call.""" + referenced = await async_extract_referenced_entity_ids(hass, service) + dev_reg = device_registry.async_get(hass) + for device_id in referenced.referenced_devices: + dev_reg_ent = dev_reg.async_get(device_id) + if not dev_reg_ent: + raise HomeAssistantError(f"No device found for device id: {device_id}") + macs = [ + cval + for ctype, cval in dev_reg_ent.connections + if ctype == device_registry.CONNECTION_NETWORK_MAC + ] + domain_data = hass.data[DOMAIN] + matching_instances = [ + domain_data[entry_id][HOMEKIT] + for entry_id in domain_data + if HOMEKIT in domain_data[entry_id] + and domain_data[entry_id][HOMEKIT].driver + and device_registry.format_mac( + domain_data[entry_id][HOMEKIT].driver.state.mac + ) + in macs + ] + if not matching_instances: + raise HomeAssistantError( + f"No homekit accessory found for device id: {device_id}" + ) + for homekit in matching_instances: + homekit.async_unpair() + + hass.services.async_register( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + async_handle_homekit_unpair, + schema=UNPAIR_SERVICE_SCHEMA, + ) + async def async_handle_homekit_service_start(service): """Handle start HomeKit service call.""" tasks = [] @@ -458,7 +512,6 @@ class HomeKit: def setup(self, async_zeroconf_instance): """Set up bridge and accessory driver.""" - ip_addr = self._ip_address or get_local_ip() persist_file = get_persist_fullpath_for_entry_id(self.hass, self._entry_id) self.driver = HomeDriver( @@ -467,7 +520,7 @@ class HomeKit: self._name, self._entry_title, loop=self.hass.loop, - address=ip_addr, + address=self._ip_address, port=self._port, persist_file=persist_file, advertised_address=self._advertise_ip, @@ -484,36 +537,64 @@ class HomeKit: self.driver.persist() - def reset_accessories(self, entity_ids): + async def async_reset_accessories(self, entity_ids): """Reset the accessory to load the latest configuration.""" if not self.bridge: - self.driver.config_changed() + await self.async_reset_accessories_in_accessory_mode(entity_ids) return + await self.async_reset_accessories_in_bridge_mode(entity_ids) - removed = [] + async def async_reset_accessories_in_accessory_mode(self, entity_ids): + """Reset accessories in accessory mode.""" + acc = self.driver.accessory + if acc.entity_id not in entity_ids: + return + acc.async_stop() + if not (state := self.hass.states.get(acc.entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) + return + if new_acc := self._async_create_single_accessory([state]): + self.driver.accessory = new_acc + self.hass.async_add_job(new_acc.run) + await self.async_config_changed() + + async def async_reset_accessories_in_bridge_mode(self, entity_ids): + """Reset accessories in bridge mode.""" + new = [] for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( "HomeKit Bridge %s will reset accessory with linked entity_id %s", self._name, entity_id, ) - acc = self.remove_bridge_accessory(aid) - removed.append(acc) + if state := self.hass.states.get(acc.entity_id): + new.append(state) + else: + _LOGGER.warning( + "The underlying entity %s disappeared during reset", acc.entity + ) - if not removed: + if not new: # No matched accessories, probably on another bridge return - self.driver.config_changed() + await self.async_config_changed() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + for state in new: + acc = self.add_bridge_accessory(state) + if acc: + self.hass.async_add_job(acc.run) + await self.async_config_changed() - for acc in removed: - self.bridge.add_accessory(acc) - self.driver.config_changed() + async def async_config_changed(self): + """Call config changed which writes out the new config to disk.""" + await self.hass.async_add_executor_job(self.driver.config_changed) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" @@ -539,7 +620,7 @@ class HomeKit: ) aid = self.aid_storage.get_or_allocate_aid_for_entity_id(state.entity_id) - conf = self._config.pop(state.entity_id, {}) + conf = self._config.get(state.entity_id, {}).copy() # If an accessory cannot be created or added due to an exception # of any kind (usually in pyhap) it should not prevent # the rest of the accessories from being created @@ -547,16 +628,18 @@ class HomeKit: acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) + return acc except Exception: # pylint: disable=broad-except _LOGGER.exception( "Failed to create a HomeKit accessory for %s", state.entity_id ) + return None def remove_bridge_accessory(self, aid): """Try adding accessory to bridge if configured beforehand.""" - acc = None - if aid in self.bridge.accessories: - acc = self.bridge.accessories.pop(aid) + acc = self.bridge.accessories.pop(aid, None) + if acc: + acc.async_stop() return acc async def async_configure_accessories(self): @@ -608,7 +691,11 @@ class HomeKit: if self.driver.state.paired: return + self._async_show_setup_message() + @callback + def _async_show_setup_message(self): + """Show the pairing setup message.""" show_setup_message( self.hass, self._entry_id, @@ -617,6 +704,16 @@ class HomeKit: self.driver.accessory.xhm_uri(), ) + @callback + def async_unpair(self): + """Remove all pairings for an accessory so it can be repaired.""" + state = self.driver.state + for client_uuid in list(state.paired_clients): + state.remove_paired_client(client_uuid) + self.driver.async_persist() + self.driver.async_update_advertisement() + self._async_show_setup_message() + @callback def _async_register_bridge(self): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" @@ -663,26 +760,45 @@ class HomeKit: for device_id in devices_to_purge: dev_reg.async_remove_device(device_id) + @callback + def _async_create_single_accessory(self, entity_states): + """Create a single HomeKit accessory (accessory mode).""" + if not entity_states: + _LOGGER.error( + "HomeKit %s cannot startup: entity not available: %s", + self._name, + self._filter.config, + ) + return None + state = entity_states[0] + conf = self._config.get(state.entity_id, {}).copy() + acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + if acc is None: + _LOGGER.error( + "HomeKit %s cannot startup: entity not supported: %s", + self._name, + self._filter.config, + ) + return acc + + @callback + def _async_create_bridge_accessory(self, entity_states): + """Create a HomeKit bridge with accessories. (bridge mode).""" + self.bridge = HomeBridge(self.hass, self.driver, self._name) + for state in entity_states: + self.add_bridge_accessory(state) + return self.bridge + async def _async_create_accessories(self): """Create the accessories.""" entity_states = await self.async_configure_accessories() if self._homekit_mode == HOMEKIT_MODE_ACCESSORY: - if not entity_states: - _LOGGER.error( - "HomeKit %s cannot startup: entity not available: %s", - self._name, - self._filter.config, - ) - return False - state = entity_states[0] - conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self.hass, self.driver, state, STANDALONE_AID, conf) + acc = self._async_create_single_accessory(entity_states) else: - self.bridge = HomeBridge(self.hass, self.driver, self._name) - for state in entity_states: - self.add_bridge_accessory(state) - acc = self.bridge + acc = self._async_create_bridge_accessory(entity_states) + if acc is None: + return False # No need to load/persist as we do it in setup self.driver.accessory = acc return True diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index b9bd62246cf..49ba1103ac5 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -58,12 +58,19 @@ from .const import ( CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, DEVICE_CLASS_PM25, + DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, HK_NOT_CHARGING, MANUFACTURER, + MAX_MANUFACTURER_LENGTH, + MAX_MODEL_LENGTH, + MAX_NAME_LENGTH, + MAX_SERIAL_LENGTH, + MAX_VERSION_LENGTH, SERV_BATTERY_SERVICE, + SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -127,7 +134,7 @@ def get_accessory(hass, driver, state, aid, config): # noqa: C901 and features & cover.SUPPORT_SET_POSITION ): a_type = "Window" - elif features & cover.SUPPORT_SET_POSITION: + elif features & (cover.SUPPORT_SET_POSITION | cover.SUPPORT_SET_TILT_POSITION): a_type = "WindowCovering" elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): a_type = "WindowCoveringBasic" @@ -215,7 +222,9 @@ class HomeAccessory(Accessory): **kwargs, ): """Initialize a Accessory object.""" - super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs) + super().__init__( + driver=driver, display_name=name[:MAX_NAME_LENGTH], aid=aid, *args, **kwargs + ) self.config = config or {} domain = split_entity_id(entity_id)[0].replace("_", " ") @@ -235,10 +244,10 @@ class HomeAccessory(Accessory): sw_version = __version__ self.set_info_service( - manufacturer=manufacturer, - model=model, - serial_number=entity_id, - firmware_revision=sw_version, + manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH], + model=model[:MAX_MODEL_LENGTH], + serial_number=entity_id[:MAX_SERIAL_LENGTH], + firmware_revision=sw_version[:MAX_VERSION_LENGTH], ) self.category = category @@ -454,6 +463,17 @@ class HomeAccessory(Accessory): ) ) + @ha_callback + def async_reset(self): + """Reset and recreate an accessory.""" + self.hass.async_create_task( + self.hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: self.entity_id}, + ) + ) + @ha_callback def async_stop(self): """Cancel any subscriptions when the bridge is stopped.""" diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index c7c3e5da833..1ec53079179 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -1,4 +1,7 @@ """Config flow for HomeKit integration.""" +from __future__ import annotations + +import asyncio import random import re import string @@ -19,7 +22,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, ) -from homeassistant.core import callback, split_entity_id +from homeassistant.core import HomeAssistant, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -27,6 +30,7 @@ from homeassistant.helpers.entityfilter import ( CONF_INCLUDE_DOMAINS, CONF_INCLUDE_ENTITIES, ) +from homeassistant.loader import async_get_integration from .const import ( CONF_AUTO_START, @@ -114,6 +118,21 @@ _EMPTY_ENTITY_FILTER = { } +async def _async_name_to_type_map(hass: HomeAssistant) -> dict[str, str]: + """Create a mapping of types of devices/entities HomeKit can support.""" + integrations = await asyncio.gather( + *(async_get_integration(hass, domain) for domain in SUPPORTED_DOMAINS), + return_exceptions=True, + ) + name_to_type_map = { + domain: domain + if isinstance(integrations[idx], Exception) + else integrations[idx].name + for idx, domain in enumerate(SUPPORTED_DOMAINS) + } + return name_to_type_map + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for HomeKit.""" @@ -133,13 +152,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hk_data[CONF_HOMEKIT_MODE] = HOMEKIT_MODE_BRIDGE default_domains = [] if self._async_current_names() else DEFAULT_DOMAINS + name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="user", data_schema=vol.Schema( { vol.Required( CONF_INCLUDE_DOMAINS, default=default_domains - ): cv.multi_select(SUPPORTED_DOMAINS), + ): cv.multi_select(name_to_type_map), } ), ) @@ -443,6 +463,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): include_entities = entity_filter.get(CONF_INCLUDE_ENTITIES) if include_entities: domains.extend(_domains_set_from_entities(include_entities)) + name_to_type_map = await _async_name_to_type_map(self.hass) return self.async_show_form( step_id="init", @@ -454,7 +475,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): vol.Required( CONF_DOMAINS, default=domains, - ): cv.multi_select(SUPPORTED_DOMAINS), + ): cv.multi_select(name_to_type_map), } ), ) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 37788f9dca7..7f413ef78df 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -99,6 +99,7 @@ HOMEKIT_MODES = [HOMEKIT_MODE_BRIDGE, HOMEKIT_MODE_ACCESSORY] # #### HomeKit Component Services #### SERVICE_HOMEKIT_START = "start" SERVICE_HOMEKIT_RESET_ACCESSORY = "reset_accessory" +SERVICE_HOMEKIT_UNPAIR = "unpair" # #### String Constants #### BRIDGE_MODEL = "Bridge" @@ -295,3 +296,10 @@ CONFIG_OPTIONS = [ CONF_ENTITY_CONFIG, CONF_HOMEKIT_MODE, ] + +# ### Maximum Lengths ### +MAX_NAME_LENGTH = 64 +MAX_SERIAL_LENGTH = 64 +MAX_MODEL_LENGTH = 64 +MAX_VERSION_LENGTH = 64 +MAX_MANUFACTURER_LENGTH = 64 diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/homekit/img_util.py index 860d798f113..7d7a45081a6 100644 --- a/homeassistant/components/homekit/img_util.py +++ b/homeassistant/components/homekit/img_util.py @@ -53,6 +53,8 @@ class TurboJPEGSingleton: def __init__(self): """Try to create TurboJPEG only once.""" + # pylint: disable=unused-private-member + # https://github.com/PyCQA/pylint/issues/4681 try: # TurboJPEG checks for libturbojpeg # when its created, but it imports diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 39c40e03614..887dfc3ee37 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,13 +3,13 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.5.1", + "HAP-python==3.6.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", "PyTurboJPEG==1.5.0" ], - "dependencies": ["http", "camera", "ffmpeg"], + "dependencies": ["http", "camera", "ffmpeg", "network"], "after_dependencies": ["zeroconf"], "codeowners": ["@bdraco"], "zeroconf": ["_homekit._tcp.local."], diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml index 315a612241f..68e7804697b 100644 --- a/homeassistant/components/homekit/services.yaml +++ b/homeassistant/components/homekit/services.yaml @@ -14,3 +14,9 @@ reset_accessory: target: entity: {} +unpair: + name: Unpair an accessory or bridge + description: Forcefully remove all pairings from an accessory to allow re-pairing. Use this service if the accessory is no longer responsive, and you want to avoid deleting and re-adding the entry. Room locations, and accessory preferences will be lost. + target: + device: + integration: homekit diff --git a/homeassistant/components/homekit/translations/ca.json b/homeassistant/components/homekit/translations/ca.json index e836d81ac05..d6bb88f1dba 100644 --- a/homeassistant/components/homekit/translations/ca.json +++ b/homeassistant/components/homekit/translations/ca.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Dominis a incloure" }, - "description": "Selecciona els dominis a incloure. S'inclouran totes les entitats del domini compatibles. Es crear\u00e0 una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV i c\u00e0mera.", + "description": "Selecciona els dominis a incloure. S'inclouran totes les entitats del domini compatibles. Es crear\u00e0 una inst\u00e0ncia HomeKit en mode accessori per a cada repoductor multim\u00e8dia/TV, control remot basat per activitat i c\u00e0mera.", "title": "Selecciona els dominis a incloure" } } diff --git a/homeassistant/components/homekit/translations/de.json b/homeassistant/components/homekit/translations/de.json index e115c932ac4..759a33fca91 100644 --- a/homeassistant/components/homekit/translations/de.json +++ b/homeassistant/components/homekit/translations/de.json @@ -5,14 +5,14 @@ }, "step": { "pairing": { - "description": "Um die Kopplung abzuschlie\u00dfen, folgen Sie den Anweisungen in \"Benachrichtigungen\" unter \"HomeKit-Kopplung\".", + "description": "Um die Kopplung abzuschlie\u00dfen, folge den Anweisungen in \"Benachrichtigungen\" unter \"HomeKit-Kopplung\".", "title": "HomeKit verbinden" }, "user": { "data": { "include_domains": "Einzubeziehende Domains" }, - "description": "W\u00e4hlen Sie die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Entit\u00e4ten in der Domain werden aufgenommen. F\u00fcr jeden TV-Mediaplayer und jede Kamera wird eine separate HomeKit-Instanz im Zubeh\u00f6rmodus erstellt.", + "description": "W\u00e4hle die Domains aus, die aufgenommen werden sollen. Alle unterst\u00fctzten Entit\u00e4ten in der Domain werden aufgenommen. F\u00fcr jeden TV-Mediaplayer und jede Kamera wird eine separate HomeKit-Instanz im Zubeh\u00f6rmodus erstellt.", "title": "W\u00e4hle die zu einzubeziehenden Dom\u00e4nen aus." } } @@ -31,14 +31,14 @@ "camera_copy": "Kameras, die native H.264-Streams unterst\u00fctzen" }, "description": "Pr\u00fcfe alle Kameras, die native H.264-Streams unterst\u00fctzen. Wenn die Kamera keinen H.264-Stream ausgibt, transkodiert das System das Video in H.264 f\u00fcr HomeKit. Die Transkodierung erfordert eine leistungsstarke CPU und wird wahrscheinlich nicht auf Einplatinencomputern funktionieren.", - "title": "W\u00e4hlen Sie den Kamera-Video-Codec." + "title": "W\u00e4hle den Kamera-Video-Codec." }, "include_exclude": { "data": { "entities": "Entit\u00e4ten", "mode": "Modus" }, - "description": "W\u00e4hlen Sie die einzubeziehenden Entit\u00e4ten aus. Im Zubeh\u00f6rmodus wird nur eine einzelne Entit\u00e4t eingeschlossen. Im Bridge-Include-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, sofern nicht bestimmte Entit\u00e4ten ausgew\u00e4hlt sind. Im Bridge-Exclude-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, au\u00dfer den ausgeschlossenen Entit\u00e4ten. F\u00fcr eine optimale Leistung wird f\u00fcr jeden TV-Media-Player, jede aktivit\u00e4tsbasierte Fernbedienung, jedes Schloss und jede Kamera ein separates HomeKit-Zubeh\u00f6r erstellt.", + "description": "W\u00e4hle die einzubeziehenden Entit\u00e4ten aus. Im Zubeh\u00f6rmodus wird nur eine einzelne Entit\u00e4t eingeschlossen. Im Bridge-Include-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, sofern nicht bestimmte Entit\u00e4ten ausgew\u00e4hlt sind. Im Bridge-Exclude-Modus werden alle Entit\u00e4ten in der Dom\u00e4ne eingeschlossen, au\u00dfer den ausgeschlossenen Entit\u00e4ten. F\u00fcr eine optimale Leistung wird f\u00fcr jeden TV-Media-Player, jede aktivit\u00e4tsbasierte Fernbedienung, jedes Schloss und jede Kamera ein separates HomeKit-Zubeh\u00f6r erstellt.", "title": "W\u00e4hle die Entit\u00e4ten aus, die aufgenommen werden sollen" }, "init": { @@ -46,7 +46,7 @@ "include_domains": "Einzubeziehende Domains", "mode": "Modus" }, - "description": "HomeKit kann so konfiguriert werden, dass eine Br\u00fccke oder ein einzelnes Zubeh\u00f6r verf\u00fcgbar gemacht wird. Im Zubeh\u00f6rmodus kann nur eine einzelne Entit\u00e4t verwendet werden. F\u00fcr Media Player mit der TV-Ger\u00e4teklasse ist ein Zubeh\u00f6rmodus erforderlich, damit sie ordnungsgem\u00e4\u00df funktionieren. Entit\u00e4ten in den \"einzuschlie\u00dfenden Dom\u00e4nen\" werden f\u00fcr HomeKit verf\u00fcgbar gemacht. Auf dem n\u00e4chsten Bildschirm k\u00f6nnen Sie ausw\u00e4hlen, welche Entit\u00e4ten in diese Liste aufgenommen oder aus dieser ausgeschlossen werden sollen.", + "description": "HomeKit kann so konfiguriert werden, dass eine Br\u00fccke oder ein einzelnes Zubeh\u00f6r verf\u00fcgbar gemacht wird. Im Zubeh\u00f6rmodus kann nur eine einzelne Entit\u00e4t verwendet werden. F\u00fcr Media Player mit der TV-Ger\u00e4teklasse ist ein Zubeh\u00f6rmodus erforderlich, damit sie ordnungsgem\u00e4\u00df funktionieren. Entit\u00e4ten in den \"einzuschlie\u00dfenden Dom\u00e4nen\" werden f\u00fcr HomeKit verf\u00fcgbar gemacht. Auf dem n\u00e4chsten Bildschirm kannst du ausw\u00e4hlen, welche Entit\u00e4ten in diese Liste aufgenommen oder aus dieser ausgeschlossen werden sollen.", "title": "W\u00e4hle die zu einzubeziehenden Dom\u00e4nen aus." }, "yaml": { diff --git a/homeassistant/components/homekit/translations/et.json b/homeassistant/components/homekit/translations/et.json index 5814ad2069b..1213200474a 100644 --- a/homeassistant/components/homekit/translations/et.json +++ b/homeassistant/components/homekit/translations/et.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Kaasatavad domeenid" }, - "description": "Vali kaasatavad domeenid. Kaasatakse k\u00f5ik domeenis toetatud olemid. Iga telemeedia pleieri ja kaamera jaoks luuakse eraldi HomeKiti eksemplar tarvikure\u017eiimis.", + "description": "Vali kaasatavad domeenid. Lisatakse k\u00f5ik domeenis toetatud \u00fcksused. Iga tv-meediam\u00e4ngija, tegevusp\u00f5hise kaugjuhtimispuldi, luku ja kaamera jaoks luuakse eraldi HomeKit-instants lisaseadme re\u017eiimis.", "title": "Vali kaasatavad domeenid" } } diff --git a/homeassistant/components/homekit/translations/it.json b/homeassistant/components/homekit/translations/it.json index 0fb983f1a20..c9afa13fb85 100644 --- a/homeassistant/components/homekit/translations/it.json +++ b/homeassistant/components/homekit/translations/it.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domini da includere" }, - "description": "Scegli i domini da includere. Verranno incluse tutte le entit\u00e0 supportate nel dominio. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessorio per ogni lettore multimediale TV e telecamera.", + "description": "Scegli i domini da includere. Tutte le entit\u00e0 supportate nel dominio saranno incluse. Verr\u00e0 creata un'istanza HomeKit separata in modalit\u00e0 accessoria per ogni lettore multimediale TV, telecomando basato sulle attivit\u00e0, serratura e telecamera.", "title": "Seleziona i domini da includere" } } diff --git a/homeassistant/components/homekit/translations/pl.json b/homeassistant/components/homekit/translations/pl.json index 2cd687ebc47..15ccb10e118 100644 --- a/homeassistant/components/homekit/translations/pl.json +++ b/homeassistant/components/homekit/translations/pl.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domeny do uwzgl\u0119dnienia" }, - "description": "Wybierz domeny do uwzgl\u0119dnienia. Wszystkie wspierane encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione. W trybie akcesorium, oddzielna instancja HomeKit zostanie utworzona dla ka\u017cdego tv media playera oraz kamery.", + "description": "Wybierz domeny do uwzgl\u0119dnienia. Wszystkie wspierane encje w danej domenie b\u0119d\u0105 uwzgl\u0119dnione. W trybie akcesorium, oddzielna instancja HomeKit zostanie utworzona dla ka\u017cdego tv media playera, pilota na bazie aktywno\u015bci, zamka oraz kamery.", "title": "Wybierz uwzgl\u0119dniane domeny" } } diff --git a/homeassistant/components/homekit/translations/zh-Hant.json b/homeassistant/components/homekit/translations/zh-Hant.json index 5ef479ff0dd..b6389567969 100644 --- a/homeassistant/components/homekit/translations/zh-Hant.json +++ b/homeassistant/components/homekit/translations/zh-Hant.json @@ -12,7 +12,7 @@ "data": { "include_domains": "\u5305\u542b\u7db2\u57df" }, - "description": "\u9078\u64c7\u6240\u8981\u5305\u542b\u7684\u7db2\u57df\uff0c\u6240\u6709\u8a72\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u5c07\u6703\u88ab\u5305\u542b\u3002 \u5176\u4ed6 Homekit \u5a92\u9ad4\u64ad\u653e\u5668\u8207\u651d\u5f71\u6a5f\u5be6\u4f8b\uff0c\u5c07\u6703\u4ee5\u914d\u4ef6\u6a21\u5f0f\u65b0\u589e\u3002", + "description": "\u9078\u64c7\u6240\u8981\u5305\u542b\u7684\u7db2\u57df\uff0c\u6240\u6709\u8a72\u7db2\u57df\u5167\u652f\u63f4\u7684\u5be6\u9ad4\u90fd\u5c07\u6703\u88ab\u5305\u542b\u3002\u6bcf\u4e00\u500b\u96fb\u8996\u5a92\u9ad4\u64ad\u653e\u5668\u3001\u9060\u7aef\u9059\u63a7\u5668\u3001\u9580\u9396\u53ca\u651d\u5f71\u6a5f\uff0c\u5c07\u4ee5 Homekit \u914d\u4ef6\u6a21\u5f0f\u65b0\u589e\u3002", "title": "\u9078\u64c7\u8981\u5305\u542b\u7684\u7db2\u57df" } } diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 48f7ad9b064..077366870e2 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -18,7 +18,6 @@ from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, ) -from homeassistant.util import get_local_ip from .accessories import TYPES, HomeAccessory from .const import ( @@ -142,9 +141,9 @@ class Camera(HomeAccessory, PyhapCamera): def __init__(self, hass, driver, name, entity_id, aid, config): """Initialize a Camera accessory object.""" self._ffmpeg = hass.data[DATA_FFMPEG] - for config_key in CONFIG_DEFAULTS: + for config_key, conf in CONFIG_DEFAULTS.items(): if config_key not in config: - config[config_key] = CONFIG_DEFAULTS[config_key] + config[config_key] = conf max_fps = config[CONF_MAX_FPS] max_width = config[CONF_MAX_WIDTH] @@ -181,7 +180,7 @@ class Camera(HomeAccessory, PyhapCamera): ] } - stream_address = config.get(CONF_STREAM_ADDRESS, get_local_ip()) + stream_address = config.get(CONF_STREAM_ADDRESS, driver.state.address) options = { "video": video_options, @@ -452,7 +451,7 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.info("[%s] Stream already stopped", session_id) return True - for shutdown_method in ["close", "kill"]: + for shutdown_method in ("close", "kill"): _LOGGER.info("[%s] %s stream", session_id, shutdown_method) try: await getattr(stream, shutdown_method)() diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index f21287b3bf8..099eced62d3 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -13,6 +13,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) @@ -53,6 +54,8 @@ from .const import ( HK_POSITION_GOING_TO_MAX, HK_POSITION_GOING_TO_MIN, HK_POSITION_STOPPED, + PROP_MAX_VALUE, + PROP_MIN_VALUE, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW, SERV_WINDOW_COVERING, @@ -273,12 +276,24 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=category, service=service) state = self.hass.states.get(self.entity_id) - self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) + target_args = {"value": 0} + if self.features & SUPPORT_SET_POSITION: + target_args["setter_callback"] = self.move_cover + else: + # If its tilt only we lock the position state to 0 (closed) + # since CHAR_CURRENT_POSITION/CHAR_TARGET_POSITION are required + # by homekit, but really don't exist. + _LOGGER.debug( + "%s does not support setting position, current position will be locked to closed", + self.entity_id, + ) + target_args["properties"] = {PROP_MIN_VALUE: 0, PROP_MAX_VALUE: 0} + self.char_target_position = self.serv_cover.configure_char( - CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover + CHAR_TARGET_POSITION, **target_args ) self.char_position_state = self.serv_cover.configure_char( CHAR_POSITION_STATE, value=HK_POSITION_STOPPED diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 1efb3b6c8be..1a0bb41774c 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -39,6 +39,7 @@ from .const import ( CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE, + MAX_NAME_LENGTH, PROP_MIN_STEP, SERV_FANV2, SERV_SWITCH, @@ -100,7 +101,8 @@ class Fan(HomeAccessory): preset_serv = self.add_preload_service(SERV_SWITCH, CHAR_NAME) serv_fan.add_linked_service(preset_serv) preset_serv.configure_char( - CHAR_NAME, value=f"{self.display_name} {preset_mode}" + CHAR_NAME, + value=f"{self.display_name} {preset_mode}"[:MAX_NAME_LENGTH], ) self.preset_mode_chars[preset_mode] = preset_serv.configure_char( diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index cb3c97fadb4..88e21272a4f 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -6,11 +6,13 @@ from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, DOMAIN, brightness_supported, color_supported, @@ -18,23 +20,18 @@ from homeassistant.components.light import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, ) from homeassistant.core import callback -from homeassistant.util.color import ( - color_temperature_mired_to_kelvin, - color_temperature_to_hs, -) from .accessories import TYPES, HomeAccessory from .const import ( CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, + CHAR_NAME, CHAR_ON, CHAR_SATURATION, PROP_MAX_VALUE, @@ -58,59 +55,99 @@ class Light(HomeAccessory): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - self.chars = [] + self.chars_primary = [] + self.chars_secondary = [] + state = self.hass.states.get(self.entity_id) + attributes = state.attributes + color_modes = attributes.get(ATTR_SUPPORTED_COLOR_MODES) + self.is_color_supported = color_supported(color_modes) + self.is_color_temp_supported = color_temp_supported(color_modes) + self.color_and_temp_supported = ( + self.is_color_supported and self.is_color_temp_supported + ) + self.is_brightness_supported = brightness_supported(color_modes) - self._features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - self._color_modes = state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) + if self.is_brightness_supported: + self.chars_primary.append(CHAR_BRIGHTNESS) - if brightness_supported(self._color_modes): - self.chars.append(CHAR_BRIGHTNESS) + if self.is_color_supported: + self.chars_primary.append(CHAR_HUE) + self.chars_primary.append(CHAR_SATURATION) - if color_supported(self._color_modes): - self.chars.append(CHAR_HUE) - self.chars.append(CHAR_SATURATION) - elif color_temp_supported(self._color_modes): - # ColorTemperature and Hue characteristic should not be - # exposed both. Both states are tracked separately in HomeKit, - # causing "source of truth" problems. - self.chars.append(CHAR_COLOR_TEMPERATURE) + if self.is_color_temp_supported: + if self.color_and_temp_supported: + self.chars_primary.append(CHAR_NAME) + self.chars_secondary.append(CHAR_NAME) + self.chars_secondary.append(CHAR_COLOR_TEMPERATURE) + if self.is_brightness_supported: + self.chars_secondary.append(CHAR_BRIGHTNESS) + else: + self.chars_primary.append(CHAR_COLOR_TEMPERATURE) - serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars) + serv_light_primary = self.add_preload_service( + SERV_LIGHTBULB, self.chars_primary + ) + serv_light_secondary = None + self.char_on_primary = serv_light_primary.configure_char(CHAR_ON, value=0) - self.char_on = serv_light.configure_char(CHAR_ON, value=0) + if self.color_and_temp_supported: + serv_light_secondary = self.add_preload_service( + SERV_LIGHTBULB, self.chars_secondary + ) + serv_light_primary.add_linked_service(serv_light_secondary) + serv_light_primary.configure_char(CHAR_NAME, value="RGB") + self.char_on_secondary = serv_light_secondary.configure_char( + CHAR_ON, value=0 + ) + serv_light_secondary.configure_char(CHAR_NAME, value="Temperature") - if CHAR_BRIGHTNESS in self.chars: + if self.is_brightness_supported: # Initial value is set to 100 because 0 is a special value (off). 100 is # an arbitrary non-zero value. It is updated immediately by async_update_state # to set to the correct initial value. - self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) + self.char_brightness_primary = serv_light_primary.configure_char( + CHAR_BRIGHTNESS, value=100 + ) + if self.chars_secondary: + self.char_brightness_secondary = serv_light_secondary.configure_char( + CHAR_BRIGHTNESS, value=100 + ) - if CHAR_COLOR_TEMPERATURE in self.chars: - min_mireds = self.hass.states.get(self.entity_id).attributes.get( - ATTR_MIN_MIREDS, 153 - ) - max_mireds = self.hass.states.get(self.entity_id).attributes.get( - ATTR_MAX_MIREDS, 500 - ) + if self.is_color_temp_supported: + min_mireds = attributes.get(ATTR_MIN_MIREDS, 153) + max_mireds = attributes.get(ATTR_MAX_MIREDS, 500) + serv_light = serv_light_secondary or serv_light_primary self.char_color_temperature = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, properties={PROP_MIN_VALUE: min_mireds, PROP_MAX_VALUE: max_mireds}, ) - if CHAR_HUE in self.chars: - self.char_hue = serv_light.configure_char(CHAR_HUE, value=0) - - if CHAR_SATURATION in self.chars: - self.char_saturation = serv_light.configure_char(CHAR_SATURATION, value=75) + if self.is_color_supported: + self.char_hue = serv_light_primary.configure_char(CHAR_HUE, value=0) + self.char_saturation = serv_light_primary.configure_char( + CHAR_SATURATION, value=75 + ) self.async_update_state(state) - serv_light.setter_callback = self._set_chars + if self.color_and_temp_supported: + serv_light_primary.setter_callback = self._set_chars_primary + serv_light_secondary.setter_callback = self._set_chars_secondary + else: + serv_light_primary.setter_callback = self._set_chars - def _set_chars(self, char_values): - _LOGGER.debug("Light _set_chars: %s", char_values) + def _set_chars_primary(self, char_values): + """Primary service is RGB or W if only color or color temp is supported.""" + self._set_chars(char_values, True) + + def _set_chars_secondary(self, char_values): + """Secondary service is W if both color or color temp are supported.""" + self._set_chars(char_values, False) + + def _set_chars(self, char_values, is_primary=None): + _LOGGER.debug("Light _set_chars: %s, is_primary: %s", char_values, is_primary) events = [] service = SERVICE_TURN_ON params = {ATTR_ENTITY_ID: self.entity_id} @@ -127,16 +164,28 @@ class Light(HomeAccessory): params[ATTR_BRIGHTNESS_PCT] = char_values[CHAR_BRIGHTNESS] events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%") - if CHAR_COLOR_TEMPERATURE in char_values: - params[ATTR_COLOR_TEMP] = char_values[CHAR_COLOR_TEMPERATURE] - events.append(f"color temperature at {char_values[CHAR_COLOR_TEMPERATURE]}") + if service == SERVICE_TURN_OFF: + self.async_call_service( + DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id}, ", ".join(events) + ) + return - if ( - color_supported(self._color_modes) - and CHAR_HUE in char_values - and CHAR_SATURATION in char_values + if self.is_color_temp_supported and ( + is_primary is False or CHAR_COLOR_TEMPERATURE in char_values ): - color = (char_values[CHAR_HUE], char_values[CHAR_SATURATION]) + params[ATTR_COLOR_TEMP] = char_values.get( + CHAR_COLOR_TEMPERATURE, self.char_color_temperature.value + ) + events.append(f"color temperature at {params[ATTR_COLOR_TEMP]}") + + if self.is_color_supported and ( + is_primary is True + or (CHAR_HUE in char_values and CHAR_SATURATION in char_values) + ): + color = ( + char_values.get(CHAR_HUE, self.char_hue.value), + char_values.get(CHAR_SATURATION, self.char_saturation.value), + ) _LOGGER.debug("%s: Set hs_color to %s", self.entity_id, color) params[ATTR_HS_COLOR] = color events.append(f"set color at {color}") @@ -148,14 +197,25 @@ class Light(HomeAccessory): """Update light after state change.""" # Handle State state = new_state.state - if state == STATE_ON and self.char_on.value != 1: - self.char_on.set_value(1) - elif state == STATE_OFF and self.char_on.value != 0: - self.char_on.set_value(0) + attributes = new_state.attributes + char_on_value = int(state == STATE_ON) + + if self.color_and_temp_supported: + color_mode = attributes.get(ATTR_COLOR_MODE) + color_temp_mode = color_mode == COLOR_MODE_COLOR_TEMP + primary_on_value = char_on_value if not color_temp_mode else 0 + secondary_on_value = char_on_value if color_temp_mode else 0 + if self.char_on_primary.value != primary_on_value: + self.char_on_primary.set_value(primary_on_value) + if self.char_on_secondary.value != secondary_on_value: + self.char_on_secondary.set_value(secondary_on_value) + else: + if self.char_on_primary.value != char_on_value: + self.char_on_primary.set_value(char_on_value) # Handle Brightness - if CHAR_BRIGHTNESS in self.chars: - brightness = new_state.attributes.get(ATTR_BRIGHTNESS) + if self.is_brightness_supported: + brightness = attributes.get(ATTR_BRIGHTNESS) if isinstance(brightness, (int, float)): brightness = round(brightness / 255 * 100, 0) # The homeassistant component might report its brightness as 0 but is @@ -170,29 +230,25 @@ class Light(HomeAccessory): # order to avoid this incorrect behavior. if brightness == 0 and state == STATE_ON: brightness = 1 - if self.char_brightness.value != brightness: - self.char_brightness.set_value(brightness) + if self.char_brightness_primary.value != brightness: + self.char_brightness_primary.set_value(brightness) + if ( + self.color_and_temp_supported + and self.char_brightness_secondary.value != brightness + ): + self.char_brightness_secondary.set_value(brightness) # Handle color temperature - if CHAR_COLOR_TEMPERATURE in self.chars: - color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) + if self.is_color_temp_supported: + color_temperature = attributes.get(ATTR_COLOR_TEMP) if isinstance(color_temperature, (int, float)): color_temperature = round(color_temperature, 0) if self.char_color_temperature.value != color_temperature: self.char_color_temperature.set_value(color_temperature) # Handle Color - if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars: - if ATTR_HS_COLOR in new_state.attributes: - hue, saturation = new_state.attributes[ATTR_HS_COLOR] - elif ATTR_COLOR_TEMP in new_state.attributes: - hue, saturation = color_temperature_to_hs( - color_temperature_mired_to_kelvin( - new_state.attributes[ATTR_COLOR_TEMP] - ) - ) - else: - hue, saturation = None, None + if self.is_color_supported: + hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): hue = round(hue, 0) saturation = round(saturation, 0) diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index 17e2eee46e8..3a10a0a2f5a 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -3,7 +3,14 @@ import logging from pyhap.const import CATEGORY_DOOR_LOCK -from homeassistant.components.lock import DOMAIN, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import ( + DOMAIN, + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import callback @@ -12,16 +19,37 @@ from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { +HASS_TO_HOMEKIT_CURRENT = { STATE_UNLOCKED: 0, + STATE_UNLOCKING: 1, + STATE_LOCKING: 0, STATE_LOCKED: 1, - # Value 2 is Jammed which hass doesn't have a state for + STATE_JAMMED: 2, STATE_UNKNOWN: 3, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +HASS_TO_HOMEKIT_TARGET = { + STATE_UNLOCKED: 0, + STATE_UNLOCKING: 0, + STATE_LOCKING: 1, + STATE_LOCKED: 1, +} -STATE_TO_SERVICE = {STATE_LOCKED: "lock", STATE_UNLOCKED: "unlock"} +VALID_TARGET_STATES = {STATE_LOCKING, STATE_UNLOCKING, STATE_LOCKED, STATE_UNLOCKED} + +HOMEKIT_TO_HASS = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} + +STATE_TO_SERVICE = { + STATE_LOCKING: "unlock", + STATE_LOCKED: "lock", + STATE_UNLOCKING: "lock", + STATE_UNLOCKED: "unlock", +} @TYPES.register("Lock") @@ -39,11 +67,11 @@ class Lock(HomeAccessory): serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( - CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT[STATE_UNKNOWN] + CHAR_LOCK_CURRENT_STATE, value=HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] ) self.char_target_state = serv_lock_mechanism.configure_char( CHAR_LOCK_TARGET_STATE, - value=HASS_TO_HOMEKIT[STATE_LOCKED], + value=HASS_TO_HOMEKIT_CURRENT[STATE_LOCKED], setter_callback=self.set_state, ) self.async_update_state(state) @@ -52,12 +80,9 @@ class Lock(HomeAccessory): """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS.get(value) + hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] - if self.char_current_state.value != value: - self.char_current_state.set_value(value) - params = {ATTR_ENTITY_ID: self.entity_id} if self._code: params[ATTR_CODE] = self._code @@ -67,25 +92,28 @@ class Lock(HomeAccessory): def async_update_state(self, new_state): """Update lock after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_lock_state = HASS_TO_HOMEKIT[hass_state] - _LOGGER.debug( - "%s: Updated current state to %s (%d)", - self.entity_id, - hass_state, - current_lock_state, - ) - # LockTargetState only supports locked and unlocked - # Must set lock target state before current state - # or there will be no notification - if ( - hass_state in (STATE_LOCKED, STATE_UNLOCKED) - and self.char_target_state.value != current_lock_state - ): - self.char_target_state.set_value(current_lock_state) + current_lock_state = HASS_TO_HOMEKIT_CURRENT.get( + hass_state, HASS_TO_HOMEKIT_CURRENT[STATE_UNKNOWN] + ) + target_lock_state = HASS_TO_HOMEKIT_TARGET.get(hass_state) + _LOGGER.debug( + "%s: Updated current state to %s (current=%d) (target=%s)", + self.entity_id, + hass_state, + current_lock_state, + target_lock_state, + ) + # LockTargetState only supports locked and unlocked + # Must set lock target state before current state + # or there will be no notification + if ( + target_lock_state is not None + and self.char_target_state.value != target_lock_state + ): + self.char_target_state.set_value(target_lock_state) - # Set lock current state ONLY after ensuring that - # target state is correct or there will be no - # notification - if self.char_current_state.value != current_lock_state: - self.char_current_state.set_value(current_lock_state) + # Set lock current state ONLY after ensuring that + # target state is correct or there will be no + # notification + if self.char_current_state.value != current_lock_state: + self.char_current_state.set_value(current_lock_state) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 5cd27109bd8..081053d2591 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -55,6 +55,7 @@ from .const import ( FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, KEY_PLAY_PAUSE, + MAX_NAME_LENGTH, SERV_SWITCH, SERV_TELEVISION_SPEAKER, ) @@ -134,7 +135,7 @@ class MediaPlayer(HomeAccessory): def generate_service_name(self, mode): """Generate name for individual service.""" - return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" + return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"[:MAX_NAME_LENGTH] def set_on_off(self, value): """Move switch state to value if call came from HomeKit.""" diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e4f18a7c16f..9e54221430c 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -47,6 +47,7 @@ from .const import ( KEY_PREVIOUS_TRACK, KEY_REWIND, KEY_SELECT, + MAX_NAME_LENGTH, SERV_INPUT_SOURCE, SERV_TELEVISION, ) @@ -87,6 +88,7 @@ class RemoteInputSelectAccessory(HomeAccessory): features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) self.source_key = source_key + self.source_list_key = source_list_key self.sources = [] self.support_select_source = False if features & required_feature: @@ -119,8 +121,10 @@ class RemoteInputSelectAccessory(HomeAccessory): SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME] ) serv_tv.add_linked_service(serv_input) - serv_input.configure_char(CHAR_CONFIGURED_NAME, value=source) - serv_input.configure_char(CHAR_NAME, value=source) + serv_input.configure_char( + CHAR_CONFIGURED_NAME, value=source[:MAX_NAME_LENGTH] + ) + serv_input.configure_char(CHAR_NAME, value=source[:MAX_NAME_LENGTH]) serv_input.configure_char(CHAR_IDENTIFIER, value=index) serv_input.configure_char(CHAR_IS_CONFIGURED, value=True) input_type = 3 if "hdmi" in source.lower() else 0 @@ -152,13 +156,26 @@ class RemoteInputSelectAccessory(HomeAccessory): index = self.sources.index(source_name) if self.char_input_source.value != index: self.char_input_source.set_value(index) - elif hk_state: - _LOGGER.warning( - "%s: Sources out of sync. Restart Home Assistant", + return + + possible_sources = new_state.attributes.get(self.source_list_key, []) + if source_name in possible_sources: + _LOGGER.debug( + "%s: Sources out of sync. Rebuilding Accessory", self.entity_id, ) - if self.char_input_source.value != 0: - self.char_input_source.set_value(0) + # Sources are out of sync, recreate the accessory + self.async_reset() + return + + _LOGGER.debug( + "%s: Source %s does not exist the source list: %s", + self.entity_id, + source_name, + possible_sources, + ) + if self.char_input_source.value != 0: + self.char_input_source.set_value(0) @TYPES.register("ActivityRemote") diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index acbf636c1c3..6fe1a4e9e29 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -2,13 +2,13 @@ import logging from pyhap.const import CATEGORY_ALARM_SYSTEM -from pyhap.loader import get_loader from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( @@ -22,6 +22,8 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -36,28 +38,43 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -HASS_TO_HOMEKIT = { - STATE_ALARM_ARMED_HOME: 0, - STATE_ALARM_ARMED_AWAY: 1, - STATE_ALARM_ARMED_NIGHT: 2, - STATE_ALARM_DISARMED: 3, - STATE_ALARM_TRIGGERED: 4, +HK_ALARM_STAY_ARMED = 0 +HK_ALARM_AWAY_ARMED = 1 +HK_ALARM_NIGHT_ARMED = 2 +HK_ALARM_DISARMED = 3 +HK_ALARM_TRIGGERED = 4 + +HASS_TO_HOMEKIT_CURRENT = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_DISARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, + STATE_ALARM_TRIGGERED: HK_ALARM_TRIGGERED, +} + +HASS_TO_HOMEKIT_TARGET = { + STATE_ALARM_ARMED_HOME: HK_ALARM_STAY_ARMED, + STATE_ALARM_ARMED_VACATION: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_AWAY: HK_ALARM_AWAY_ARMED, + STATE_ALARM_ARMED_NIGHT: HK_ALARM_NIGHT_ARMED, + STATE_ALARM_ARMING: HK_ALARM_AWAY_ARMED, + STATE_ALARM_DISARMED: HK_ALARM_DISARMED, } HASS_TO_HOMEKIT_SERVICES = { - SERVICE_ALARM_ARM_HOME: 0, - SERVICE_ALARM_ARM_AWAY: 1, - SERVICE_ALARM_ARM_NIGHT: 2, - SERVICE_ALARM_DISARM: 3, + SERVICE_ALARM_ARM_HOME: HK_ALARM_STAY_ARMED, + SERVICE_ALARM_ARM_AWAY: HK_ALARM_AWAY_ARMED, + SERVICE_ALARM_ARM_NIGHT: HK_ALARM_NIGHT_ARMED, + SERVICE_ALARM_DISARM: HK_ALARM_DISARMED, } -HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} - -STATE_TO_SERVICE = { - STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, - STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, - STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, - STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM, +HK_TO_SERVICE = { + HK_ALARM_AWAY_ARMED: SERVICE_ALARM_ARM_AWAY, + HK_ALARM_STAY_ARMED: SERVICE_ALARM_ARM_HOME, + HK_ALARM_NIGHT_ARMED: SERVICE_ALARM_ARM_NIGHT, + HK_ALARM_DISARMED: SERVICE_ALARM_DISARM, } @@ -75,65 +92,51 @@ class SecuritySystem(HomeAccessory): ATTR_SUPPORTED_FEATURES, ( SUPPORT_ALARM_ARM_HOME + | SUPPORT_ALARM_ARM_VACATION | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER ), ) - loader = get_loader() - default_current_states = loader.get_char( - "SecuritySystemCurrentState" - ).properties.get("ValidValues") - default_target_services = loader.get_char( - "SecuritySystemTargetState" - ).properties.get("ValidValues") + serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) + current_char = serv_alarm.get_characteristic(CHAR_CURRENT_SECURITY_STATE) + target_char = serv_alarm.get_characteristic(CHAR_TARGET_SECURITY_STATE) + default_current_states = current_char.properties.get("ValidValues") + default_target_services = target_char.properties.get("ValidValues") - current_supported_states = [ - HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - HASS_TO_HOMEKIT[STATE_ALARM_TRIGGERED], - ] - target_supported_services = [HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM]] + current_supported_states = [HK_ALARM_DISARMED, HK_ALARM_TRIGGERED] + target_supported_services = [HK_ALARM_DISARMED] if supported_states & SUPPORT_ALARM_ARM_HOME: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_HOME]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_HOME] - ) + current_supported_states.append(HK_ALARM_STAY_ARMED) + target_supported_services.append(HK_ALARM_STAY_ARMED) - if supported_states & SUPPORT_ALARM_ARM_AWAY: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_AWAY]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_AWAY] - ) + if supported_states & (SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_VACATION): + current_supported_states.append(HK_ALARM_AWAY_ARMED) + target_supported_services.append(HK_ALARM_AWAY_ARMED) if supported_states & SUPPORT_ALARM_ARM_NIGHT: - current_supported_states.append(HASS_TO_HOMEKIT[STATE_ALARM_ARMED_NIGHT]) - target_supported_services.append( - HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_ARM_NIGHT] - ) + current_supported_states.append(HK_ALARM_NIGHT_ARMED) + target_supported_services.append(HK_ALARM_NIGHT_ARMED) - new_current_states = { - key: val - for key, val in default_current_states.items() - if val in current_supported_states - } - new_target_services = { - key: val - for key, val in default_target_services.items() - if val in target_supported_services - } - - serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) self.char_current_state = serv_alarm.configure_char( CHAR_CURRENT_SECURITY_STATE, - value=HASS_TO_HOMEKIT[STATE_ALARM_DISARMED], - valid_values=new_current_states, + value=HASS_TO_HOMEKIT_CURRENT[STATE_ALARM_DISARMED], + valid_values={ + key: val + for key, val in default_current_states.items() + if val in current_supported_states + }, ) self.char_target_state = serv_alarm.configure_char( CHAR_TARGET_SECURITY_STATE, value=HASS_TO_HOMEKIT_SERVICES[SERVICE_ALARM_DISARM], - valid_values=new_target_services, + valid_values={ + key: val + for key, val in default_target_services.items() + if val in target_supported_services + }, setter_callback=self.set_security_state, ) @@ -144,9 +147,7 @@ class SecuritySystem(HomeAccessory): def set_security_state(self, value): """Move security state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) - hass_value = HOMEKIT_TO_HASS[value] - service = STATE_TO_SERVICE[hass_value] - + service = HK_TO_SERVICE[value] params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code @@ -156,20 +157,16 @@ class SecuritySystem(HomeAccessory): def async_update_state(self, new_state): """Update security state after state changed.""" hass_state = new_state.state - if hass_state in HASS_TO_HOMEKIT: - current_security_state = HASS_TO_HOMEKIT[hass_state] - if self.char_current_state.value != current_security_state: - self.char_current_state.set_value(current_security_state) + if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: + if self.char_current_state.value != current_state: + self.char_current_state.set_value(current_state) _LOGGER.debug( "%s: Updated current state to %s (%d)", self.entity_id, hass_state, - current_security_state, + current_state, ) - # SecuritySystemTargetState does not support triggered - if ( - hass_state != STATE_ALARM_TRIGGERED - and self.char_target_state.value != current_security_state - ): - self.char_target_state.set_value(current_security_state) + if (target_state := HASS_TO_HOMEKIT_TARGET.get(hass_state)) is not None: + if self.char_target_state.value != target_state: + self.char_target_state.set_value(target_state) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 8ea19897420..381110a4e79 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -55,6 +55,9 @@ VALVE_TYPE = { } +ACTIVATE_ONLY_SWITCH_DOMAINS = {"scene", "script"} + + @TYPES.register("Outlet") class Outlet(HomeAccessory): """Generate an Outlet accessory.""" @@ -98,7 +101,7 @@ class Switch(HomeAccessory): def __init__(self, *args): """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) - self._domain = split_entity_id(self.entity_id)[0] + self._domain, self._object_id = split_entity_id(self.entity_id) state = self.hass.states.get(self.entity_id) self.activate_only = self.is_activate(self.hass.states.get(self.entity_id)) @@ -113,9 +116,7 @@ class Switch(HomeAccessory): def is_activate(self, state): """Check if entity is activate only.""" - if self._domain == "scene": - return True - return False + return self._domain in ACTIVATE_ONLY_SWITCH_DOMAINS def reset_switch(self, *args): """Reset switch to emulate activate click.""" @@ -129,8 +130,14 @@ class Switch(HomeAccessory): if self.activate_only and not value: _LOGGER.debug("%s: Ignoring turn_off call", self.entity_id) return + params = {ATTR_ENTITY_ID: self.entity_id} - service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + if self._domain == "script": + service = self._object_id + params = {} + else: + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.async_call_service(self._domain, service, params) if self.activate_only: diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index f7507d09837..c14cfbb8a7e 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -189,11 +189,16 @@ class CharacteristicEntity(HomeKitEntity): the service entity. """ + def __init__(self, accessory, devinfo, char): + """Initialise a generic single characteristic HomeKit entity.""" + self._char = char + super().__init__(accessory, devinfo) + @property def unique_id(self) -> str: """Return the ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) - return f"homekit-{serial}-aid:{self._aid}-sid:{self._iid}-cid:{self._iid}" + return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" async def async_setup_entry(hass, entry): @@ -228,10 +233,10 @@ async def async_setup(hass, config): async def _async_stop_homekit_controller(event): await asyncio.gather( - *[ + *( connection.async_unload() for connection in hass.data[KNOWN_DEVICES].values() - ] + ) ) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_homekit_controller) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index e8357a4001d..cc4addfae4f 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -3,6 +3,7 @@ import logging import re import aiohomekit +from aiohomekit.exceptions import AuthenticationError import voluptuous as vol from homeassistant import config_entries @@ -270,7 +271,32 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # invalid. Remove it automatically. existing = find_existing_host(self.hass, hkid) if not paired and existing: - await self.hass.config_entries.async_remove(existing.entry_id) + if self.controller is None: + await self._async_setup_controller() + + pairing = self.controller.load_pairing( + existing.data["AccessoryPairingID"], dict(existing.data) + ) + try: + await pairing.list_accessories_and_characteristics() + except AuthenticationError: + _LOGGER.debug( + "%s (%s - %s) is unpaired. Removing invalid pairing for this device", + name, + model, + hkid, + ) + await self.hass.config_entries.async_remove(existing.entry_id) + else: + _LOGGER.debug( + "%s (%s - %s) claims to be unpaired but isn't. " + "It's implementation of HomeKit is defective " + "or a zeroconf relay is broadcasting stale data", + name, + model, + hkid, + ) + return self.async_abort(reason="already_paired") # Set unique-id and error out if it's already configured self._abort_if_unique_id_configured(updates=updated_ip_port) diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 9fbc8fc4c62..5b4c87f53e4 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -46,5 +46,10 @@ HOMEKIT_ACCESSORY_DISPATCH = { CHARACTERISTIC_PLATFORMS = { CharacteristicsTypes.Vendor.EVE_ENERGY_WATT: "sensor", CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: "sensor", + CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: "sensor", + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: "number", CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): "sensor", + CharacteristicsTypes.get_uuid( + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT + ): "sensor", } diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index dfddd29f2ff..1505ead993b 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -41,7 +41,6 @@ class HomeKitHumidifier(HomeKitEntity, HumidifierEntity): """Define the homekit characteristics the entity cares about.""" return [ CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, @@ -143,7 +142,6 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): """Define the homekit characteristics the entity cares about.""" return [ CharacteristicsTypes.ACTIVE, - CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT, CharacteristicsTypes.CURRENT_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.TARGET_HUMIDIFIER_DEHUMIDIFIER_STATE, CharacteristicsTypes.RELATIVE_HUMIDITY_HUMIDIFIER_THRESHOLD, diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 09c02ce0ff9..3b6fb41f3a8 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -2,18 +2,28 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes -from homeassistant.components.lock import LockEntity -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.components.lock import STATE_JAMMED, LockEntity +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + STATE_LOCKED, + STATE_UNKNOWN, + STATE_UNLOCKED, +) from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity -STATE_JAMMED = "jammed" - -CURRENT_STATE_MAP = {0: STATE_UNLOCKED, 1: STATE_LOCKED, 2: STATE_JAMMED, 3: None} +CURRENT_STATE_MAP = { + 0: STATE_UNLOCKED, + 1: STATE_LOCKED, + 2: STATE_JAMMED, + 3: STATE_UNKNOWN, +} TARGET_STATE_MAP = {STATE_UNLOCKED: 0, STATE_LOCKED: 1} +REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()} + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Homekit lock.""" @@ -46,8 +56,44 @@ class HomeKitLock(HomeKitEntity, LockEntity): def is_locked(self): """Return true if device is locked.""" value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + if CURRENT_STATE_MAP[value] == STATE_UNKNOWN: + return None return CURRENT_STATE_MAP[value] == STATE_LOCKED + @property + def is_locking(self): + """Return true if device is locking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_UNLOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_LOCKED + ) + + @property + def is_unlocking(self): + """Return true if device is unlocking.""" + current_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE + ) + target_value = self.service.value( + CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE + ) + return ( + CURRENT_STATE_MAP[current_value] == STATE_LOCKED + and REVERSED_TARGET_STATE_MAP.get(target_value) == STATE_UNLOCKED + ) + + @property + def is_jammed(self): + """Return true if device is jammed.""" + value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + return CURRENT_STATE_MAP[value] == STATE_JAMMED + async def async_lock(self, **kwargs): """Lock the device.""" await self._set_lock_state(STATE_LOCKED) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 4bc61f53cc0..a4644d0e34a 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.5.1"], + "requirements": ["aiohomekit==0.6.0"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py new file mode 100644 index 00000000000..73d8cd6adbd --- /dev/null +++ b/homeassistant/components/homekit_controller/number.py @@ -0,0 +1,99 @@ +""" +Support for Homekit number ranges. + +These are mostly used where a HomeKit accessory exposes additional non-standard +characteristics that don't map to a Home Assistant feature. +""" +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes + +from homeassistant.components.number import NumberEntity +from homeassistant.core import callback + +from . import KNOWN_DEVICES, CharacteristicEntity + +NUMBER_ENTITIES = { + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL: { + "name": "Spray Quantity", + "icon": "mdi:water", + } +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit numbers.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_characteristic(char: Characteristic): + kwargs = NUMBER_ENTITIES.get(char.type) + if not kwargs: + return False + info = {"aid": char.service.accessory.aid, "iid": char.service.iid} + async_add_entities([HomeKitNumber(conn, info, char, **kwargs)], True) + return True + + conn.add_char_factory(async_add_characteristic) + + +class HomeKitNumber(CharacteristicEntity, NumberEntity): + """Representation of a Number control on a homekit accessory.""" + + def __init__( + self, + conn, + info, + char, + device_class=None, + icon=None, + name=None, + **kwargs, + ): + """Initialise a HomeKit number control.""" + self._device_class = device_class + self._icon = icon + self._name = name + + super().__init__(conn, info, char) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [self._char.type] + + @property + def device_class(self): + """Return type of sensor.""" + return self._device_class + + @property + def icon(self): + """Return the sensor icon.""" + return self._icon + + @property + def min_value(self) -> float: + """Return the minimum value.""" + return self._char.minValue + + @property + def max_value(self) -> float: + """Return the maximum value.""" + return self._char.maxValue + + @property + def step(self) -> float: + """Return the increment/decrement step.""" + return self._char.minStep + + @property + def value(self) -> float: + """Return the current characteristic value.""" + return self._char.value + + async def async_set_value(self, value: float): + """Set the characteristic to this value.""" + await self.async_put_characteristics( + { + self._char.type: value, + } + ) diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index b21010e9b1e..91b62b0d572 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, LIGHT_LUX, PERCENTAGE, + POWER_WATT, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -29,13 +30,19 @@ SIMPLE_SENSOR = { "name": "Real Time Energy", "device_class": DEVICE_CLASS_POWER, "state_class": STATE_CLASS_MEASUREMENT, - "unit": "watts", + "unit": POWER_WATT, }, CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY: { "name": "Real Time Energy", "device_class": DEVICE_CLASS_POWER, "state_class": STATE_CLASS_MEASUREMENT, - "unit": "watts", + "unit": POWER_WATT, + }, + CharacteristicsTypes.Vendor.KOOGEEK_REALTIME_ENERGY_2: { + "name": "Real Time Energy", + "device_class": DEVICE_CLASS_POWER, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": POWER_WATT, }, CharacteristicsTypes.get_uuid(CharacteristicsTypes.TEMPERATURE_CURRENT): { "name": "Current Temperature", @@ -47,6 +54,16 @@ SIMPLE_SENSOR = { "probe": lambda char: char.service.type != ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR), }, + CharacteristicsTypes.get_uuid(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT): { + "name": "Current Humidity", + "device_class": DEVICE_CLASS_HUMIDITY, + "state_class": STATE_CLASS_MEASUREMENT, + "unit": PERCENTAGE, + # This sensor is only for humidity characteristics that are not part + # of a humidity sensor service. + "probe": lambda char: char.service.type + != ServicesTypes.get_uuid(ServicesTypes.HUMIDITY_SENSOR), + }, } @@ -238,9 +255,8 @@ class SimpleSensor(CharacteristicEntity, SensorEntity): self._unit = unit self._icon = icon self._name = name - self._char = char - super().__init__(conn, info) + super().__init__(conn, info, char) def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" diff --git a/homeassistant/components/homekit_controller/translations/de.json b/homeassistant/components/homekit_controller/translations/de.json index 7df1d0fc1a7..248d3871b3e 100644 --- a/homeassistant/components/homekit_controller/translations/de.json +++ b/homeassistant/components/homekit_controller/translations/de.json @@ -21,11 +21,11 @@ "flow_title": "{name}", "step": { "busy_error": { - "description": "Brechen Sie das Pairing auf allen Controllern ab oder versuchen Sie, das Ger\u00e4t neu zu starten, und fahren Sie dann fort, das Pairing fortzusetzen.", + "description": "Breche das Pairing auf allen Controllern ab oder versuche, das Ger\u00e4t neu zu starten, und fahre dann fort, das Pairing fortzusetzen.", "title": "Das Ger\u00e4t wird bereits mit einem anderen Controller gekoppelt" }, "max_tries_error": { - "description": "Das Ger\u00e4t hat mehr als 100 erfolglose Authentifizierungsversuche erhalten. Versuchen Sie, das Ger\u00e4t neu zu starten, und fahren Sie dann fort, die Kopplung fortzusetzen.", + "description": "Das Ger\u00e4t hat mehr als 100 erfolglose Authentifizierungsversuche erhalten. Versuche, das Ger\u00e4t neu zu starten, und fahre dann fort, die Kopplung fortzusetzen.", "title": "Maximale Authentifizierungsversuche \u00fcberschritten" }, "pair": { @@ -33,11 +33,11 @@ "allow_insecure_setup_codes": "Pairing mit unsicheren Setup-Codes zulassen.", "pairing_code": "Kopplungscode" }, - "description": "HomeKit Controller kommuniziert mit {name} \u00fcber das lokale Netzwerk mit einer sicheren verschl\u00fcsselten Verbindung ohne separaten HomeKit Controller oder iCloud. Geben Sie Ihren HomeKit-Kopplungscode (im Format XXX-XX-XXX) ein, um dieses Zubeh\u00f6r zu verwenden. Dieser Code befindet sich in der Regel auf dem Ger\u00e4t selbst oder in der Verpackung.", + "description": "HomeKit Controller kommuniziert mit {name} \u00fcber das lokale Netzwerk mit einer sicheren verschl\u00fcsselten Verbindung ohne separaten HomeKit Controller oder iCloud. Gib deinen HomeKit-Kopplungscode (im Format XXX-XX-XXX) ein, um dieses Zubeh\u00f6r zu verwenden. Dieser Code befindet sich in der Regel auf dem Ger\u00e4t selbst oder in der Verpackung.", "title": "Mit HomeKit Zubeh\u00f6r koppeln" }, "protocol_error": { - "description": "Das Ger\u00e4t befindet sich m\u00f6glicherweise nicht im Pairing-Modus und erfordert einen physischen oder virtuellen Tastendruck. Stellen Sie sicher, dass sich das Ger\u00e4t im Pairing-Modus befindet, oder versuchen Sie, das Ger\u00e4t neu zu starten und fahren Sie dann das Pairing fort.", + "description": "Das Ger\u00e4t befindet sich m\u00f6glicherweise nicht im Pairing-Modus und erfordert einen physischen oder virtuellen Tastendruck. Stelle sicher, dass sich das Ger\u00e4t im Pairing-Modus befindet, oder versuche, das Ger\u00e4t neu zu starten und fahre dann das Pairing fort.", "title": "Fehler bei der Kommunikation mit dem Zubeh\u00f6r" }, "user": { diff --git a/homeassistant/components/homekit_controller/translations/fr.json b/homeassistant/components/homekit_controller/translations/fr.json index 5ae7a0faafd..faf970f88eb 100644 --- a/homeassistant/components/homekit_controller/translations/fr.json +++ b/homeassistant/components/homekit_controller/translations/fr.json @@ -12,6 +12,7 @@ }, "error": { "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.", + "insecure_setup_code": "Le code de configuration demand\u00e9 n'est pas s\u00e9curis\u00e9 en raison de sa nature triviale. Cet accessoire ne r\u00e9pond pas aux exigences de s\u00e9curit\u00e9 de base.", "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.", "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.", "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.", @@ -29,6 +30,7 @@ }, "pair": { "data": { + "allow_insecure_setup_codes": "Autoriser le jumelage avec des codes de configuration non s\u00e9curis\u00e9s.", "pairing_code": "Code d\u2019appairage" }, "description": "Le contr\u00f4leur HomeKit communique avec {name} sur le r\u00e9seau local en utilisant une connexion crypt\u00e9e s\u00e9curis\u00e9e sans contr\u00f4leur HomeKit s\u00e9par\u00e9 ou iCloud. Entrez votre code d'appariement HomeKit (au format XXX-XX-XXX) pour utiliser cet accessoire. Ce code se trouve g\u00e9n\u00e9ralement sur l'appareil lui-m\u00eame ou dans l'emballage.", diff --git a/homeassistant/components/homekit_controller/translations/hu.json b/homeassistant/components/homekit_controller/translations/hu.json index 90e2405ed64..cd06d12e809 100644 --- a/homeassistant/components/homekit_controller/translations/hu.json +++ b/homeassistant/components/homekit_controller/translations/hu.json @@ -7,10 +7,12 @@ "already_paired": "Ez a tartoz\u00e9k m\u00e1r p\u00e1ros\u00edtva van egy m\u00e1sik eszk\u00f6zzel. \u00c1ll\u00edtsa alaphelyzetbe a tartoz\u00e9kot, majd pr\u00f3b\u00e1lkozzon \u00fajra.", "ignored_model": "A HomeKit t\u00e1mogat\u00e1sa e modelln\u00e9l blokkolva van, mivel a szolg\u00e1ltat\u00e1shoz teljes nat\u00edv integr\u00e1ci\u00f3 \u00e9rhet\u0151 el.", "invalid_config_entry": "Ez az eszk\u00f6z k\u00e9szen \u00e1ll a p\u00e1ros\u00edt\u00e1sra, de m\u00e1r van egy \u00fctk\u00f6z\u0151 konfigur\u00e1ci\u00f3s bejegyz\u00e9s a Home Assistant-ben, amelyet el\u0151sz\u00f6r el kell t\u00e1vol\u00edtani.", + "invalid_properties": "Az eszk\u00f6z \u00e1ltal bejelentett \u00e9rv\u00e9nytelen tulajdons\u00e1gok.", "no_devices": "Nem tal\u00e1lhat\u00f3 nem p\u00e1ros\u00edtott eszk\u00f6z" }, "error": { "authentication_error": "Helytelen HomeKit k\u00f3d. K\u00e9rj\u00fck, ellen\u0151rizze, \u00e9s pr\u00f3b\u00e1lja \u00fajra.", + "insecure_setup_code": "A k\u00e9rt telep\u00edt\u00e9si k\u00f3d trivi\u00e1lis jellege miatt nem biztons\u00e1gos. Ez a tartoz\u00e9k nem felel meg az alapvet\u0151 biztons\u00e1gi k\u00f6vetelm\u00e9nyeknek.", "max_peers_error": "Az eszk\u00f6z megtagadta a p\u00e1ros\u00edt\u00e1s hozz\u00e1ad\u00e1s\u00e1t, mivel nincs ingyenes p\u00e1ros\u00edt\u00e1si t\u00e1rhely.", "pairing_failed": "Nem kezelt hiba t\u00f6rt\u00e9nt az eszk\u00f6zzel val\u00f3 p\u00e1ros\u00edt\u00e1s sor\u00e1n. Lehet, hogy ez \u00e1tmeneti hiba, vagy az eszk\u00f6z jelenleg m\u00e9g nem t\u00e1mogatott.", "unable_to_pair": "Nem siker\u00fclt p\u00e1ros\u00edtani, pr\u00f3b\u00e1ld \u00fajra.", @@ -18,13 +20,24 @@ }, "flow_title": "HomeKit tartoz\u00e9k: {name}", "step": { + "busy_error": { + "title": "Az eszk\u00f6z m\u00e1r p\u00e1rosul egy m\u00e1sik vez\u00e9rl\u0151vel" + }, + "max_tries_error": { + "description": "Az eszk\u00f6z t\u00f6bb mint 100 sikertelen hiteles\u00edt\u00e9si k\u00eds\u00e9rletet kapott. Ind\u00edtsa \u00fajra az eszk\u00f6zt, majd folytassa a p\u00e1ros\u00edt\u00e1s folytat\u00e1s\u00e1t.", + "title": "T\u00fall\u00e9pte a maxim\u00e1lis hiteles\u00edt\u00e9si k\u00eds\u00e9rleteket" + }, "pair": { "data": { + "allow_insecure_setup_codes": "P\u00e1ros\u00edt\u00e1s enged\u00e9lyez\u00e9se a nem biztons\u00e1gos be\u00e1ll\u00edt\u00e1si k\u00f3dokkal.", "pairing_code": "P\u00e1ros\u00edt\u00e1si k\u00f3d" }, "description": "\u00cdrja be a HomeKit p\u00e1ros\u00edt\u00e1si k\u00f3dj\u00e1t (XXX-XX-XXX form\u00e1tumban) a kieg\u00e9sz\u00edt\u0151 haszn\u00e1lat\u00e1hoz", "title": "HomeKit tartoz\u00e9k p\u00e1ros\u00edt\u00e1sa" }, + "protocol_error": { + "title": "Hiba t\u00f6rt\u00e9nt a tartoz\u00e9kkal val\u00f3 kommunik\u00e1ci\u00f3 sor\u00e1n" + }, "user": { "data": { "device": "Eszk\u00f6z" diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 62f2f0ccdff..ad62001d5f9 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -8,6 +8,8 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_MILLIMETERS, @@ -17,7 +19,6 @@ from homeassistant.const import ( PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - VOLT, VOLUME_CUBIC_METERS, ) @@ -47,8 +48,8 @@ HM_UNIT_HA_CAST = { "ACTUAL_TEMPERATURE": TEMP_CELSIUS, "BRIGHTNESS": "#", "POWER": POWER_WATT, - "CURRENT": "mA", - "VOLTAGE": VOLT, + "CURRENT": ELECTRIC_CURRENT_MILLIAMPERE, + "VOLTAGE": ELECTRIC_POTENTIAL_VOLT, "ENERGY_COUNTER": ENERGY_WATT_HOUR, "GAS_POWER": VOLUME_CUBIC_METERS, "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 2d3e1ea518c..843f44510c1 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -15,6 +15,9 @@ from homematicip.base.enums import DoorCommand, DoorState from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, + DEVICE_CLASS_BLIND, + DEVICE_CLASS_GARAGE, + DEVICE_CLASS_SHUTTER, CoverEntity, ) from homeassistant.config_entries import ConfigEntry @@ -63,6 +66,11 @@ async def async_setup_entry( class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP blind module.""" + @property + def device_class(self) -> str: + """Return the class of the cover.""" + return DEVICE_CLASS_BLIND + @property def current_cover_position(self) -> int: """Return current position of cover.""" @@ -151,6 +159,11 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): hap, device, channel=channel, is_multi_channel=is_multi_channel ) + @property + def device_class(self) -> str: + """Return the class of the cover.""" + return DEVICE_CLASS_SHUTTER + @property def current_cover_position(self) -> int: """Return current position of cover.""" @@ -264,6 +277,11 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): } return door_state_to_position.get(self._device.doorState) + @property + def device_class(self) -> str: + """Return the class of the cover.""" + return DEVICE_CLASS_GARAGE + @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" @@ -290,6 +308,11 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): device.modelType = f"HmIP-{post}" super().__init__(hap, device, post, is_multi_channel=False) + @property + def device_class(self) -> str: + """Return the class of the cover.""" + return DEVICE_CLASS_SHUTTER + @property def current_cover_position(self) -> int: """Return current position of cover.""" diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index f82e2c19996..b41c7b06c74 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,7 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==0.13.1"], + "requirements": ["homematicip==1.0.1"], "codeowners": [], "quality_scale": "platinum", "iot_class": "cloud_push" diff --git a/homeassistant/components/homematicip_cloud/translations/ar.json b/homeassistant/components/homematicip_cloud/translations/ar.json new file mode 100644 index 00000000000..5685fa738c7 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/translations/ar.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "register_failed": "\u0641\u0634\u0644 \u0627\u0644\u062a\u0633\u062c\u064a\u0644 \u060c \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/translations/de.json b/homeassistant/components/homematicip_cloud/translations/de.json index 1da1e06c0fb..3cb74491c7f 100644 --- a/homeassistant/components/homematicip_cloud/translations/de.json +++ b/homeassistant/components/homematicip_cloud/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Der Accesspoint ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "connection_aborted": "Verbindung fehlgeschlagen", "unknown": "Unerwarteter Fehler" }, diff --git a/homeassistant/components/homematicip_cloud/translations/he.json b/homeassistant/components/homematicip_cloud/translations/he.json index b6a75868d3b..55260a82f9d 100644 --- a/homeassistant/components/homematicip_cloud/translations/he.json +++ b/homeassistant/components/homematicip_cloud/translations/he.json @@ -22,7 +22,7 @@ }, "link": { "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d5\u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e9\u05dc\u05d9\u05d7\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d7\u05d1\u05e8 \u05d0\u05ea HomematicIP \u05e2\u05ddHome Assistant.\n\n![\u05de\u05d9\u05e7\u05d5\u05dd \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d1\u05de\u05d2\u05e9\u05e8](/static/images/config_flows/config_homematicip_cloud.png)", - "title": "\u05d7\u05d1\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4" + "title": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e7\u05d9\u05e9\u05d5\u05e8" } } } diff --git a/homeassistant/components/homematicip_cloud/translations/hu.json b/homeassistant/components/homematicip_cloud/translations/hu.json index eaa8d8834c3..90fee286a3a 100644 --- a/homeassistant/components/homematicip_cloud/translations/hu.json +++ b/homeassistant/components/homematicip_cloud/translations/hu.json @@ -21,6 +21,7 @@ "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot" }, "link": { + "description": "A HomematicIP regisztr\u00e1l\u00e1s\u00e1hoz a Home Assistant alkalmaz\u00e1sban nyomja meg a hozz\u00e1f\u00e9r\u00e9si pont k\u00e9k gombj\u00e1t \u00e9s a bek\u00fcld\u00e9s gombot. \n\n ! [A gomb helye a h\u00eddon] (/ static / images / config_flows / config_homematicip_cloud.png)", "title": "Link Hozz\u00e1f\u00e9r\u00e9si pont" } } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 57176c9acf8..48f2802e89f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -1 +1,132 @@ """Support for Honeywell (US) Total Connect Comfort climate systems.""" +from datetime import timedelta + +import somecomfort + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import Throttle + +from .const import _LOGGER, CONF_DEV_ID, CONF_LOC_ID, DOMAIN + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=180) +PLATFORMS = ["climate"] + + +async def async_setup_entry(hass, config): + """Set up the Honeywell thermostat.""" + username = config.data[CONF_USERNAME] + password = config.data[CONF_PASSWORD] + + client = await hass.async_add_executor_job( + get_somecomfort_client, username, password + ) + + if client is None: + return False + + loc_id = config.data.get(CONF_LOC_ID) + dev_id = config.data.get(CONF_DEV_ID) + + devices = [] + + for location in client.locations_by_id.values(): + for device in location.devices_by_id.values(): + if (not loc_id or location.locationid == loc_id) and ( + not dev_id or device.deviceid == dev_id + ): + devices.append(device) + + if len(devices) == 0: + _LOGGER.debug("No devices found") + return False + + data = HoneywellService(hass, client, username, password, devices[0]) + await data.update() + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config.entry_id] = data + hass.config_entries.async_setup_platforms(config, PLATFORMS) + + return True + + +def get_somecomfort_client(username, password): + """Initialize the somecomfort client.""" + try: + return somecomfort.SomeComfort(username, password) + except somecomfort.AuthError: + _LOGGER.error("Failed to login to honeywell account %s", username) + return None + except somecomfort.SomeComfortError as ex: + raise ConfigEntryNotReady( + "Failed to initialize the Honeywell client: " + "Check your configuration (username, password), " + "or maybe you have exceeded the API rate limit?" + ) from ex + + +class HoneywellService: + """Get the latest data and update.""" + + def __init__(self, hass, client, username, password, device): + """Initialize the data object.""" + self._hass = hass + self._client = client + self._username = username + self._password = password + self.device = device + + async def _retry(self) -> bool: + """Recreate a new somecomfort client. + + When we got an error, the best way to be sure that the next query + will succeed, is to recreate a new somecomfort client. + """ + self._client = await self._hass.async_add_executor_job( + get_somecomfort_client, self._username, self._password + ) + + if self._client is None: + return False + + devices = [ + device + for location in self._client.locations_by_id.values() + for device in location.devices_by_id.values() + if device.name == self.device.name + ] + + if len(devices) != 1: + _LOGGER.error("Failed to find device %s", self.device.name) + return False + + self.device = devices[0] + return True + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def update(self) -> None: + """Update the state.""" + retries = 3 + while retries > 0: + try: + await self._hass.async_add_executor_job(self.device.refresh) + break + except ( + somecomfort.client.APIRateLimited, + OSError, + somecomfort.client.ConnectionTimeout, + ) as exp: + retries -= 1 + if retries == 0: + raise exp + + result = await self._hass.async_add_executor_job(self._retry()) + + if not result: + raise exp + + _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) + + _LOGGER.debug( + "latestData = %s ", self.device._data # pylint: disable=protected-access + ) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 8053ad85502..36fe16aeaa2 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -2,10 +2,8 @@ from __future__ import annotations import datetime -import logging from typing import Any -import requests import somecomfort import voluptuous as vol @@ -33,6 +31,7 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_TEMPERATURE, CONF_PASSWORD, @@ -42,19 +41,21 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.device_registry as dr -_LOGGER = logging.getLogger(__name__) +from .const import ( + _LOGGER, + CONF_COOL_AWAY_TEMPERATURE, + CONF_DEV_ID, + CONF_HEAT_AWAY_TEMPERATURE, + CONF_LOC_ID, + DEFAULT_COOL_AWAY_TEMPERATURE, + DEFAULT_HEAT_AWAY_TEMPERATURE, + DOMAIN, +) ATTR_FAN_ACTION = "fan_action" -CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" -CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" -CONF_DEV_ID = "thermostat" -CONF_LOC_ID = "location" - -DEFAULT_COOL_AWAY_TEMPERATURE = 88 -DEFAULT_HEAT_AWAY_TEMPERATURE = 61 - ATTR_PERMANENT_HOLD = "permanent_hold" PLATFORM_SCHEMA = vol.All( @@ -108,95 +109,88 @@ HW_FAN_MODE_TO_HA = { } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_entry(hass, config, async_add_entities, discovery_info=None): """Set up the Honeywell thermostat.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) + cool_away_temp = config.data.get(CONF_COOL_AWAY_TEMPERATURE) + heat_away_temp = config.data.get(CONF_HEAT_AWAY_TEMPERATURE) - try: - client = somecomfort.SomeComfort(username, password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", username) - return - except somecomfort.SomeComfortError: - _LOGGER.error( - "Failed to initialize the Honeywell client: " - "Check your configuration (username, password), " - "or maybe you have exceeded the API rate limit?" + data = hass.data[DOMAIN][config.entry_id] + + async_add_entities([HoneywellUSThermostat(data, cool_away_temp, heat_away_temp)]) + + +async def async_setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Honeywell climate platform. + + Honeywell uses config flow for configuration now. If an entry exists in + configuration.yaml, the import flow will attempt to import it and create + a config entry. + """ + + if config["platform"] == "honeywell": + _LOGGER.warning( + "Loading honeywell via platform config is deprecated; The configuration" + " has been migrated to a config entry and can be safely removed" ) - return - - dev_id = config.get(CONF_DEV_ID) - loc_id = config.get(CONF_LOC_ID) - cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE) - heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE) - - add_entities( - [ - HoneywellUSThermostat( - client, - device, - cool_away_temp, - heat_away_temp, - username, - password, + # No config entry exists and configuration.yaml config exists, trigger the import flow. + if not hass.config_entries.async_entries(DOMAIN): + await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - for location in client.locations_by_id.values() - for device in location.devices_by_id.values() - if ( - (not loc_id or location.locationid == loc_id) - and (not dev_id or device.deviceid == dev_id) - ) - ] - ) class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" - def __init__( - self, client, device, cool_away_temp, heat_away_temp, username, password - ): + def __init__(self, data, cool_away_temp, heat_away_temp): """Initialize the thermostat.""" - self._client = client - self._device = device + self._data = data self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False - self._username = username - self._password = password - _LOGGER.debug("latestData = %s ", device._data) + self._attr_unique_id = dr.format_mac(data.device.mac_address) + self._attr_name = data.device.name + self._attr_temperature_unit = ( + TEMP_CELSIUS if data.device.temperature_unit == "C" else TEMP_FAHRENHEIT + ) + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] + self._attr_is_aux_heat = data.device.system_mode == "emheat" # not all honeywell HVACs support all modes - mappings = [v for k, v in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[k]] + mappings = [ + v for k, v in HVAC_MODE_TO_HW_MODE.items() if data.device.raw_ui_data[k] + ] self._hvac_mode_map = {k: v for d in mappings for k, v in d.items()} + self._attr_hvac_modes = list(self._hvac_mode_map) - self._supported_features = ( + self._attr_supported_features = ( SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_RANGE ) - if device._data["canControlHumidification"]: - self._supported_features |= SUPPORT_TARGET_HUMIDITY + if data.device._data["canControlHumidification"]: + self._attr_supported_features |= SUPPORT_TARGET_HUMIDITY - if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: - self._supported_features |= SUPPORT_AUX_HEAT + if data.device.raw_ui_data["SwitchEmergencyHeatAllowed"]: + self._attr_supported_features |= SUPPORT_AUX_HEAT - if not device._data["hasFan"]: + if not data.device._data["hasFan"]: return # not all honeywell fans support all modes - mappings = [v for k, v in FAN_MODE_TO_HW.items() if device.raw_fan_data[k]] + mappings = [v for k, v in FAN_MODE_TO_HW.items() if data.device.raw_fan_data[k]] self._fan_mode_map = {k: v for d in mappings for k, v in d.items()} - self._supported_features |= SUPPORT_FAN_MODE + self._attr_fan_modes = list(self._fan_mode_map) + + self._attr_supported_features |= SUPPORT_FAN_MODE @property - def name(self) -> str | None: - """Return the name of the honeywell, if any.""" - return self._device.name + def _device(self): + """Shortcut to access the device.""" + return self._data.device @property def extra_state_attributes(self) -> dict[str, Any]: @@ -208,11 +202,6 @@ class HoneywellUSThermostat(ClimateEntity): data["dr_phase"] = self._device.raw_dr_data.get("Phase") return data - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return self._supported_features - @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -231,11 +220,6 @@ class HoneywellUSThermostat(ClimateEntity): return self._device.raw_ui_data["HeatUpperSetptLimit"] return None - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS if self._device.temperature_unit == "C" else TEMP_FAHRENHEIT - @property def current_humidity(self) -> int | None: """Return the current humidity.""" @@ -246,11 +230,6 @@ class HoneywellUSThermostat(ClimateEntity): """Return hvac operation ie. heat, cool mode.""" return HW_MODE_TO_HVAC_MODE[self._device.system_mode] - @property - def hvac_modes(self) -> list[str]: - """Return the list of available hvac operation modes.""" - return list(self._hvac_mode_map) - @property def hvac_action(self) -> str | None: """Return the current running hvac operation if supported.""" @@ -291,26 +270,11 @@ class HoneywellUSThermostat(ClimateEntity): """Return the current preset mode, e.g., home, away, temp.""" return PRESET_AWAY if self._away else None - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return [PRESET_NONE, PRESET_AWAY] - - @property - def is_aux_heat(self) -> str | None: - """Return true if aux heater.""" - return self._device.system_mode == "emheat" - @property def fan_mode(self) -> str | None: """Return the fan setting.""" return HW_FAN_MODE_TO_HA[self._device.fan_mode] - @property - def fan_modes(self) -> list[str] | None: - """Return the list of available fan modes.""" - return list(self._fan_mode_map) - def _is_permanent_hold(self) -> bool: heat_status = self._device.raw_ui_data.get("StatusHeat", 0) cool_status = self._device.raw_ui_data.get("StatusCool", 0) @@ -383,7 +347,9 @@ class HoneywellUSThermostat(ClimateEntity): setattr(self._device, f"hold_{mode}", True) # Set temperature setattr( - self._device, f"setpoint_{mode}", getattr(self, f"_{mode}_away_temp") + self._device, + f"setpoint_{mode}", + getattr(self, f"_{mode}_away_temp"), ) except somecomfort.SomeComfortError: _LOGGER.error( @@ -418,54 +384,6 @@ class HoneywellUSThermostat(ClimateEntity): else: self.set_hvac_mode(HVAC_MODE_OFF) - def _retry(self) -> bool: - """Recreate a new somecomfort client. - - When we got an error, the best way to be sure that the next query - will succeed, is to recreate a new somecomfort client. - """ - try: - self._client = somecomfort.SomeComfort(self._username, self._password) - except somecomfort.AuthError: - _LOGGER.error("Failed to login to honeywell account %s", self._username) - return False - except somecomfort.SomeComfortError as ex: - _LOGGER.error("Failed to initialize honeywell client: %s", str(ex)) - return False - - devices = [ - device - for location in self._client.locations_by_id.values() - for device in location.devices_by_id.values() - if device.name == self._device.name - ] - - if len(devices) != 1: - _LOGGER.error("Failed to find device %s", self._device.name) - return False - - self._device = devices[0] - return True - - def update(self) -> None: - """Update the state.""" - retries = 3 - while retries > 0: - try: - self._device.refresh() - break - except ( - somecomfort.client.APIRateLimited, - OSError, - requests.exceptions.ReadTimeout, - ) as exp: - retries -= 1 - if retries == 0: - raise exp - if not self._retry(): - raise exp - _LOGGER.error("SomeComfort update failed, Retrying - Error: %s", exp) - - _LOGGER.debug( - "latestData = %s ", self._device._data # pylint: disable=protected-access - ) + async def async_update(self): + """Get the latest state from the service.""" + await self._data.update() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py new file mode 100644 index 00000000000..318809aaa03 --- /dev/null +++ b/homeassistant/components/honeywell/config_flow.py @@ -0,0 +1,55 @@ +"""Config flow to configure the honeywell integration.""" +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.honeywell import get_somecomfort_client +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN + + +class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a honeywell config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Create config entry. Show the setup form to the user.""" + errors = {} + + if user_input is not None: + valid = await self.is_valid(**user_input) + if valid: + return self.async_create_entry( + title=DOMAIN, + data=user_input, + ) + + errors["base"] = "invalid_auth" + + data_schema = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + return self.async_show_form( + step_id="user", data_schema=vol.Schema(data_schema), errors=errors + ) + + async def is_valid(self, **kwargs) -> bool: + """Check if login credentials are valid.""" + client = await self.hass.async_add_executor_job( + get_somecomfort_client, kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD] + ) + + return client is not None + + async def async_step_import(self, import_data): + """Import entry from configuration.yaml.""" + return await self.async_step_user( + { + CONF_USERNAME: import_data[CONF_USERNAME], + CONF_PASSWORD: import_data[CONF_PASSWORD], + CONF_COOL_AWAY_TEMPERATURE: import_data[CONF_COOL_AWAY_TEMPERATURE], + CONF_HEAT_AWAY_TEMPERATURE: import_data[CONF_HEAT_AWAY_TEMPERATURE], + } + ) diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py new file mode 100644 index 00000000000..6102f30d3de --- /dev/null +++ b/homeassistant/components/honeywell/const.py @@ -0,0 +1,13 @@ +"""Support for Honeywell (US) Total Connect Comfort climate systems.""" +import logging + +DOMAIN = "honeywell" + +DEFAULT_COOL_AWAY_TEMPERATURE = 88 +DEFAULT_HEAT_AWAY_TEMPERATURE = 61 +CONF_COOL_AWAY_TEMPERATURE = "away_cool_temperature" +CONF_HEAT_AWAY_TEMPERATURE = "away_heat_temperature" +CONF_DEV_ID = "thermostat" +CONF_LOC_ID = "location" + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index bd0c5dfca6d..5ba4947e046 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -1,8 +1,9 @@ { "domain": "honeywell", "name": "Honeywell Total Connect Comfort (US)", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/honeywell", "requirements": ["somecomfort==0.5.2"], - "codeowners": [], + "codeowners": ["@rdfurman"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json new file mode 100644 index 00000000000..ce76b571996 --- /dev/null +++ b/homeassistant/components/honeywell/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "user": { + "title": "Honeywell Total Connect Comfort (US)", + "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/honeywell/translations/ca.json b/homeassistant/components/honeywell/translations/ca.json new file mode 100644 index 00000000000..34da1b89f10 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Introdueix les credencials utilitzades per iniciar sessi\u00f3 a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (EUA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/cs.json b/homeassistant/components/honeywell/translations/cs.json new file mode 100644 index 00000000000..25ad431df4e --- /dev/null +++ b/homeassistant/components/honeywell/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/de.json b/homeassistant/components/honeywell/translations/de.json new file mode 100644 index 00000000000..a146d442eef --- /dev/null +++ b/homeassistant/components/honeywell/translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Bitte gib die Anmeldedaten ein, mit denen du dich bei mytotalconnectcomfort.com anmeldest.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/en.json b/homeassistant/components/honeywell/translations/en.json new file mode 100644 index 00000000000..454093c5b3e --- /dev/null +++ b/homeassistant/components/honeywell/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/et.json b/homeassistant/components/honeywell/translations/et.json new file mode 100644 index 00000000000..264a1efeca5 --- /dev/null +++ b/homeassistant/components/honeywell/translations/et.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "Sisesta saidile mytotalconnectcomfort.com sisenemiseks kasutatav mandaat.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/fr.json b/homeassistant/components/honeywell/translations/fr.json new file mode 100644 index 00000000000..b9b625eb589 --- /dev/null +++ b/homeassistant/components/honeywell/translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Veuillez saisir les informations d'identification utilis\u00e9es pour vous connecter \u00e0 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u00c9tats-Unis)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/he.json b/homeassistant/components/honeywell/translations/he.json new file mode 100644 index 00000000000..fc7a38e2658 --- /dev/null +++ b/homeassistant/components/honeywell/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/it.json b/homeassistant/components/honeywell/translations/it.json new file mode 100644 index 00000000000..52c828ddcde --- /dev/null +++ b/homeassistant/components/honeywell/translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Inserisci le credenziali utilizzate per accedere a mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/nl.json b/homeassistant/components/honeywell/translations/nl.json new file mode 100644 index 00000000000..0abd80fa088 --- /dev/null +++ b/homeassistant/components/honeywell/translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Voer de inloggegevens in die zijn gebruikt om in te loggen op mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (US)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/pl.json b/homeassistant/components/honeywell/translations/pl.json new file mode 100644 index 00000000000..c109565e33a --- /dev/null +++ b/homeassistant/components/honeywell/translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce u\u017cywane na mytotalconnectcomfort.com", + "title": "Honeywell Total Connect Comfort (USA)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/ru.json b/homeassistant/components/honeywell/translations/ru.json new file mode 100644 index 00000000000..1d775e6c2c7 --- /dev/null +++ b/homeassistant/components/honeywell/translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0432\u0445\u043e\u0434\u0430 \u043d\u0430 mytotalconnectcomfort.com.", + "title": "Honeywell Total Connect Comfort (\u0421\u0428\u0410)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/zh-Hant.json b/homeassistant/components/honeywell/translations/zh-Hant.json new file mode 100644 index 00000000000..906506d41a5 --- /dev/null +++ b/homeassistant/components/honeywell/translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8acb\u8f38\u5165\u767b\u5165 mytotalconnectcomfort.com \u4e4b\u6191\u8b49\u3002", + "title": "Honeywell Total Connect Comfort\uff08\u7f8e\u570b\uff09" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 10776f11b00..6e4f5c0a661 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -217,7 +217,7 @@ async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> list[IpBa def update_ip_bans_config(path: str, ip_ban: IpBan) -> None: """Update config file with new banned IP address.""" - with open(path, "a") as out: + with open(path, "a", encoding="utf8") as out: ip_ = {str(ip_ban.ip_address): {ATTR_BANNED_AT: ip_ban.banned_at.isoformat()}} out.write("\n") out.write(yaml.dump(ip_)) diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index d32eebf6d5f..ccbe6a31de2 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -8,7 +8,13 @@ import smbus import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import CONF_NAME, PERCENTAGE, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + TEMP_FAHRENHEIT, +) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit @@ -32,6 +38,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +DEVICE_CLASS_MAP = { + SENSOR_TEMPERATURE: DEVICE_CLASS_TEMPERATURE, + SENSOR_HUMIDITY: DEVICE_CLASS_HUMIDITY, +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the HTU21D sensor.""" @@ -79,6 +90,7 @@ class HTU21DSensor(SensorEntity): self._unit_of_measurement = unit self._client = htu21d_client self._state = None + self._attr_device_class = DEVICE_CLASS_MAP[variable] @property def name(self) -> str: diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 7503c1d5e71..81b715b71fe 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -4,15 +4,11 @@ from __future__ import annotations from collections import defaultdict from contextlib import suppress from datetime import timedelta -from functools import partial -import ipaddress import logging import time from typing import Any, Callable, cast -from urllib.parse import urlparse import attr -from getmac import get_mac_address from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection @@ -34,6 +30,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_RECIPIENT, @@ -41,12 +38,13 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery, + entity_registry, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -56,6 +54,8 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ADMIN_SERVICES, ALL_KEYS, + ATTR_UNIQUE_ID, + CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, @@ -81,6 +81,7 @@ from .const import ( SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, ) +from .utils import get_device_macs _LOGGER = logging.getLogger(__name__) @@ -131,11 +132,10 @@ CONFIG_ENTRY_PLATFORMS = ( class Router: """Class for router state.""" + hass: HomeAssistant = attr.ib() config_entry: ConfigEntry = attr.ib() connection: Connection = attr.ib() url: str = attr.ib() - mac: str = attr.ib() - signal_update: CALLBACK_TYPE = attr.ib() data: dict[str, Any] = attr.ib(init=False, factory=dict) subscriptions: dict[str, set[str]] = attr.ib( @@ -165,15 +165,15 @@ class Router: @property def device_identifiers(self) -> set[tuple[str, str]]: """Get router identifiers for device registry.""" - try: - return {(DOMAIN, self.data[KEY_DEVICE_INFORMATION]["SerialNumber"])} - except (KeyError, TypeError): - return set() + assert self.config_entry.unique_id is not None + return {(DOMAIN, self.config_entry.unique_id)} @property def device_connections(self) -> set[tuple[str, str]]: """Get router connections for device registry.""" - return {(dr.CONNECTION_NETWORK_MAC, self.mac)} if self.mac else set() + return { + (dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC] + } def _get_data(self, key: str, func: Callable[[], Any]) -> None: if not self.subscriptions.get(key): @@ -271,7 +271,7 @@ class Router: KEY_WLAN_WIFI_FEATURE_SWITCH, self.client.wlan.wifi_feature_switch ) - self.signal_update() + dispatcher_send(self.hass, UPDATE_SIGNAL, self.config_entry.unique_id) def logout(self) -> None: """Log out router session.""" @@ -304,7 +304,9 @@ class HuaweiLteData: routers: dict[str, Router] = attr.ib(init=False, factory=dict) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Huawei LTE component from config entry.""" url = entry.data[CONF_URL] @@ -342,61 +344,92 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, **new_options}, ) - # Get MAC address for use in unique ids. Being able to use something - # from the API would be nice, but all of that seems to be available only - # through authenticated calls (e.g. device_information.SerialNumber), and - # we want this available and the same when unauthenticated too. - host = urlparse(url).hostname - try: - if ipaddress.ip_address(host).version == 6: - mode = "ip6" - else: - mode = "ip" - except ValueError: - mode = "hostname" - mac = await hass.async_add_executor_job(partial(get_mac_address, **{mode: host})) - def get_connection() -> Connection: - """ - Set up a connection. - - Authorized one if username/pass specified (even if empty), unauthorized one otherwise. - """ - username = entry.data.get(CONF_USERNAME) - password = entry.data.get(CONF_PASSWORD) - if username or password: - connection: Connection = AuthorizedConnection( + """Set up a connection.""" + if entry.options.get(CONF_UNAUTHENTICATED_MODE): + _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") + connection = Connection(url, timeout=CONNECTION_TIMEOUT) + else: + _LOGGER.debug("Connecting in authenticated mode, full feature set") + username = entry.data.get(CONF_USERNAME) or "" + password = entry.data.get(CONF_PASSWORD) or "" + connection = AuthorizedConnection( url, username=username, password=password, timeout=CONNECTION_TIMEOUT ) - else: - connection = Connection(url, timeout=CONNECTION_TIMEOUT) return connection - def signal_update() -> None: - """Signal updates to data.""" - dispatcher_send(hass, UPDATE_SIGNAL, url) - try: connection = await hass.async_add_executor_job(get_connection) except Timeout as ex: raise ConfigEntryNotReady from ex - # Set up router and store reference to it - router = Router(entry, connection, url, mac, signal_update) - hass.data[DOMAIN].routers[url] = router + # Set up router + router = Router(hass, entry, connection, url) # Do initial data update await hass.async_add_executor_job(router.update) + # Check that we found required information + device_info = router.data.get(KEY_DEVICE_INFORMATION) + if not entry.unique_id: + # Transitional from < 2021.8: update None config entry and entity unique ids + if device_info and (serial_number := device_info.get("SerialNumber")): + hass.config_entries.async_update_entry(entry, unique_id=serial_number) + ent_reg = entity_registry.async_get(hass) + for entity_entry in entity_registry.async_entries_for_config_entry( + ent_reg, entry.entry_id + ): + if not entity_entry.unique_id.startswith("None-"): + continue + new_unique_id = ( + f"{serial_number}-{entity_entry.unique_id.split('-', 1)[1]}" + ) + ent_reg.async_update_entity( + entity_entry.entity_id, new_unique_id=new_unique_id + ) + else: + await hass.async_add_executor_job(router.cleanup) + msg = ( + "Could not resolve serial number to use as unique id for router at %s" + ", setup failed" + ) + if not entry.data.get(CONF_PASSWORD): + msg += ( + ". Try setting up credentials for the router for one startup, " + "unauthenticated mode can be enabled after that in integration " + "settings" + ) + _LOGGER.error(msg, url) + return False + + # Store reference to router + hass.data[DOMAIN].routers[entry.unique_id] = router + # Clear all subscriptions, enabled entities will push back theirs router.subscriptions.clear() + # Update device MAC addresses on record. These can change due to toggling between + # authenticated and unauthenticated modes, or likely also when enabling/disabling + # SSIDs in the router config. + try: + wlan_settings = await hass.async_add_executor_job( + router.client.wlan.multi_basic_settings + ) + except Exception: # pylint: disable=broad-except + # Assume not supported, or authentication required but in unauthenticated mode + wlan_settings = {} + macs = get_device_macs(device_info or {}, wlan_settings) + # Be careful not to overwrite a previous, more complete set with a partial one + if macs and (not entry.data[CONF_MAC] or (device_info and wlan_settings)): + new_data = dict(entry.data) + new_data[CONF_MAC] = macs + hass.config_entries.async_update_entry(entry, data=new_data) + # Set up device registry if router.device_identifiers or router.device_connections: device_data = {} sw_version = None - if router.data.get(KEY_DEVICE_INFORMATION): - device_info = router.data[KEY_DEVICE_INFORMATION] + if device_info: sw_version = device_info.get("SoftwareVersion") if device_info.get("DeviceName"): device_data["model"] = device_info["DeviceName"] @@ -425,7 +458,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: NOTIFY_DOMAIN, DOMAIN, { - CONF_URL: url, + ATTR_UNIQUE_ID: entry.unique_id, CONF_NAME: entry.options.get(CONF_NAME, DEFAULT_NOTIFY_SERVICE_NAME), CONF_RECIPIENT: entry.options.get(CONF_RECIPIENT), }, @@ -462,7 +495,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Forget about the router and invoke its cleanup - router = hass.data[DOMAIN].routers.pop(config_entry.data[CONF_URL]) + router = hass.data[DOMAIN].routers.pop(config_entry.unique_id) await hass.async_add_executor_job(router.cleanup) return True @@ -483,10 +516,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: domain_config[url_normalize(router_config.pop(CONF_URL))] = router_config def service_handler(service: ServiceCall) -> None: - """Apply a service.""" + """ + Apply a service. + + We key this using the router URL instead of its unique id / serial number, + because the latter is not available anywhere in the UI. + """ routers = hass.data[DOMAIN].routers if url := service.data.get(CONF_URL): - router = routers.get(url) + router = next( + (router for router in routers.values() if router.url == url), None + ) elif not routers: _LOGGER.error("%s: no routers configured", service.service) return @@ -496,7 +536,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.error( "%s: more than one router configured, must specify one of URLs %s", service.service, - sorted(routers), + sorted(router.url for router in routers.values()), ) return if not router: @@ -560,6 +600,12 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, options=options) _LOGGER.info("Migrated config entry to version %d", config_entry.version) + if config_entry.version == 2: + config_entry.version = 3 + data = dict(config_entry.data) + data[CONF_MAC] = [] + hass.config_entries.async_update_entry(config_entry, data=data) + _LOGGER.info("Migrated config entry to version %d", config_entry.version) return True @@ -584,7 +630,7 @@ class HuaweiLteBaseEntity(Entity): @property def unique_id(self) -> str: """Return unique ID for entity.""" - return f"{self.router.mac}-{self._device_unique_id}" + return f"{self.router.config_entry.unique_id}-{self._device_unique_id}" @property def name(self) -> str: diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 556ed6f5b43..85cfd00d7aa 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -34,7 +33,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] entities: list[Entity] = [] if router.data.get(KEY_MONITORING_STATUS): diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 3a0a0c32404..a3e7390802f 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -7,7 +7,7 @@ from urllib.parse import urlparse from huawei_lte_api.AuthorizedConnection import AuthorizedConnection from huawei_lte_api.Client import Client -from huawei_lte_api.Connection import Connection +from huawei_lte_api.Connection import GetResponseType from huawei_lte_api.exceptions import ( LoginErrorPasswordWrongException, LoginErrorUsernamePasswordOverrunException, @@ -22,6 +22,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.const import ( + CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_RECIPIENT, @@ -34,12 +35,15 @@ from homeassistant.helpers.typing import DiscoveryInfoType from .const import ( CONF_TRACK_WIRED_CLIENTS, + CONF_UNAUTHENTICATED_MODE, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, DEFAULT_TRACK_WIRED_CLIENTS, + DEFAULT_UNAUTHENTICATED_MODE, DOMAIN, ) +from .utils import get_device_macs _LOGGER = logging.getLogger(__name__) @@ -47,7 +51,7 @@ _LOGGER = logging.getLogger(__name__) class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Huawei LTE config flow.""" - VERSION = 2 + VERSION = 3 @staticmethod @callback @@ -76,10 +80,10 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ): str, vol.Optional( - CONF_USERNAME, default=user_input.get(CONF_USERNAME, "") + CONF_USERNAME, default=user_input.get(CONF_USERNAME) or "" ): str, vol.Optional( - CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") + CONF_PASSWORD, default=user_input.get(CONF_PASSWORD) or "" ): str, } ), @@ -92,15 +96,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle import initiated config flow.""" return await self.async_step_user(user_input) - def _already_configured(self, user_input: dict[str, Any]) -> bool: - """See if we already have a router matching user input configured.""" - existing_urls = { - url_normalize(entry.data[CONF_URL], default_scheme="http") - for entry in self._async_current_entries() - } - return user_input[CONF_URL] in existing_urls - - async def async_step_user( # noqa: C901 + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle user initiated config flow.""" @@ -119,68 +115,46 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input=user_input, errors=errors ) - if self._already_configured(user_input): - return self.async_abort(reason="already_configured") - - conn: Connection | None = None + conn: AuthorizedConnection def logout() -> None: - if isinstance(conn, AuthorizedConnection): - try: - conn.user.logout() - except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) + try: + conn.user.logout() + except Exception: # pylint: disable=broad-except + _LOGGER.debug("Could not logout", exc_info=True) - def try_connect(user_input: dict[str, Any]) -> Connection: + def try_connect(user_input: dict[str, Any]) -> AuthorizedConnection: """Try connecting with given credentials.""" - username = user_input.get(CONF_USERNAME) - password = user_input.get(CONF_PASSWORD) - conn: Connection - if username or password: - conn = AuthorizedConnection( - user_input[CONF_URL], - username=username, - password=password, - timeout=CONNECTION_TIMEOUT, - ) - else: - try: - conn = AuthorizedConnection( - user_input[CONF_URL], - username="", - password="", - timeout=CONNECTION_TIMEOUT, - ) - user_input[CONF_USERNAME] = "" - user_input[CONF_PASSWORD] = "" - except ResponseErrorException: - _LOGGER.debug( - "Could not login with empty credentials, proceeding unauthenticated", - exc_info=True, - ) - conn = Connection(user_input[CONF_URL], timeout=CONNECTION_TIMEOUT) - del user_input[CONF_USERNAME] - del user_input[CONF_PASSWORD] + username = user_input.get(CONF_USERNAME) or "" + password = user_input.get(CONF_PASSWORD) or "" + conn = AuthorizedConnection( + user_input[CONF_URL], + username=username, + password=password, + timeout=CONNECTION_TIMEOUT, + ) return conn - def get_router_title(conn: Connection) -> str: - """Get title for router.""" - title = None + def get_device_info() -> tuple[GetResponseType, GetResponseType]: + """Get router info.""" client = Client(conn) try: - info = client.device.basic_information() + device_info = client.device.information() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not get device.basic_information", exc_info=True) - else: - title = info.get("devicename") - if not title: + _LOGGER.debug("Could not get device.information", exc_info=True) try: - info = client.device.information() + device_info = client.device.basic_information() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not get device.information", exc_info=True) - else: - title = info.get("DeviceName") - return title or DEFAULT_DEVICE_NAME + _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 + _LOGGER.debug("Could not get wlan.multi_basic_settings", exc_info=True) + wlan_settings = {} + return device_info, wlan_settings try: conn = await self.hass.async_add_executor_job(try_connect, user_input) @@ -207,11 +181,25 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input=user_input, errors=errors ) - title = self.context.get("title_placeholders", {}).get( - CONF_NAME - ) or await self.hass.async_add_executor_job(get_router_title, conn) + info, wlan_settings = await self.hass.async_add_executor_job(get_device_info) await self.hass.async_add_executor_job(logout) + if not self.unique_id: + if serial_number := info.get("SerialNumber"): + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + else: + await self._async_handle_discovery_without_unique_id() + + user_input[CONF_MAC] = get_device_macs(info, wlan_settings) + + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or info.get("DeviceName") # device.information + or info.get("devicename") # device.basic_information + or DEFAULT_DEVICE_NAME + ) + return self.async_create_entry(title=title, data=user_input) async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: @@ -224,21 +212,20 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if "mobile" not in discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, "").lower(): return self.async_abort(reason="not_huawei_lte") - url = self.context[CONF_URL] = url_normalize( + url = url_normalize( discovery_info.get( ssdp.ATTR_UPNP_PRESENTATION_URL, f"http://{urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname}/", ) ) - if any( - url == flow["context"].get(CONF_URL) for flow in self._async_in_progress() - ): - return self.async_abort(reason="already_in_progress") + if serial_number := discovery_info.get(ssdp.ATTR_UPNP_SERIAL): + await self.async_set_unique_id(serial_number) + self._abort_if_unique_id_configured() + else: + await self._async_handle_discovery_without_unique_id() user_input = {CONF_URL: url} - if self._already_configured(user_input): - return self.async_abort(reason="already_configured") self.context["title_placeholders"] = { CONF_NAME: discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME) @@ -289,6 +276,12 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS ), ): bool, + vol.Optional( + CONF_UNAUTHENTICATED_MODE, + default=self.config_entry.options.get( + CONF_UNAUTHENTICATED_MODE, DEFAULT_UNAUTHENTICATED_MODE + ), + ): bool, } ) return self.async_show_form(step_id="init", data_schema=data_schema) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 7e34b3dbd16..b9cbf546087 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,11 +2,15 @@ DOMAIN = "huawei_lte" +ATTR_UNIQUE_ID = "unique_id" + CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" +CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" DEFAULT_DEVICE_NAME = "LTE" DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN DEFAULT_TRACK_WIRED_CLIENTS = True +DEFAULT_UNAUTHENTICATED_MODE = False UPDATE_SIGNAL = f"{DOMAIN}_update" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index 61d2bf30fb9..5c451f71545 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -14,7 +14,6 @@ from homeassistant.components.device_tracker.const import ( SOURCE_TYPE_ROUTER, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -61,7 +60,7 @@ async def async_setup_entry( # Grab hosts list once to examine whether the initial fetch has got some data for # us, i.e. if wlan host list is supported. Only set up a subscription and proceed # with adding and tracking entities if it is. - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] if (hosts := _get_hosts(router, True)) is None: return @@ -94,10 +93,10 @@ async def async_setup_entry( router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN) router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) - async def _async_maybe_add_new_entities(url: str) -> None: + async def _async_maybe_add_new_entities(unique_id: str) -> None: """Add new entities if the update signal comes from our router.""" - if url == router.url: - async_add_new_entities(hass, url, async_add_entities, tracked) + if config_entry.unique_id == unique_id: + async_add_new_entities(router, async_add_entities, tracked) # Register to handle router data updates disconnect_dispatcher = async_dispatcher_connect( @@ -106,7 +105,7 @@ async def async_setup_entry( config_entry.async_on_unload(disconnect_dispatcher) # Add new entities from initial scan - async_add_new_entities(hass, router.url, async_add_entities, tracked) + async_add_new_entities(router, async_add_entities, tracked) def _is_wireless(host: _HostType) -> bool: @@ -129,13 +128,11 @@ def _is_us(host: _HostType) -> bool: @callback def async_add_new_entities( - hass: HomeAssistant, - router_url: str, + router: Router, async_add_entities: AddEntitiesCallback, tracked: set[str], ) -> None: """Add new entities that are not already being tracked.""" - router = hass.data[DOMAIN].routers[router_url] hosts = _get_hosts(router) if not hosts: return diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 5d3c15f634a..9cfc008921b 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -4,7 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ - "getmac==0.8.2", "huawei-lte-api==1.4.18", "stringcase==1.2.0", "url-normalize==1.4.1" diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 1b3b85b6711..fab19427637 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -9,11 +9,11 @@ import attr from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT, CONF_URL +from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from . import Router -from .const import DOMAIN +from .const import ATTR_UNIQUE_ID, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ async def async_get_service( if discovery_info is None: return None - router = hass.data[DOMAIN].routers[discovery_info[CONF_URL]] + router = hass.data[DOMAIN].routers[discovery_info[ATTR_UNIQUE_ID]] default_targets = discovery_info[CONF_RECIPIENT] or [] return HuaweiLteSmsNotificationService(router, default_targets) diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 7396502793e..4340d5912c9 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -16,9 +16,9 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_URL, DATA_BYTES, DATA_RATE_BYTES_PER_SECOND, + FREQUENCY_MEGAHERTZ, PERCENTAGE, STATE_UNKNOWN, TIME_SECONDS, @@ -193,11 +193,11 @@ SENSOR_META: dict[str | tuple[str, str], SensorMeta] = { ), (KEY_DEVICE_SIGNAL, "ltedlfreq"): SensorMeta( name="Downlink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), ), (KEY_DEVICE_SIGNAL, "lteulfreq"): SensorMeta( name="Uplink frequency", - formatter=lambda x: (round(int(x) / 10), "MHz"), + formatter=lambda x: (round(int(x) / 10), FREQUENCY_MEGAHERTZ), ), KEY_MONITORING_CHECK_NOTIFICATIONS: SensorMeta( exclude=re.compile( @@ -360,7 +360,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] sensors: list[Entity] = [] for key in SENSOR_KEYS: if not (items := router.data.get(key)): diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 9cfa49604ae..0c1373192c5 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -1,8 +1,6 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_huawei_lte": "Not a Huawei LTE device" }, "error": { @@ -23,7 +21,7 @@ "url": "[%key:common::config_flow::data::url%]", "username": "[%key:common::config_flow::data::username%]" }, - "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "description": "Enter device access details.", "title": "Configure Huawei LTE" } } @@ -34,7 +32,8 @@ "data": { "name": "Notification service name (change requires restart)", "recipient": "SMS notification recipients", - "track_wired_clients": "Track wired network clients" + "track_wired_clients": "Track wired network clients", + "unauthenticated_mode": "Unauthenticated mode (change requires reload)" } } } diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index ff4109943bc..a4fd393346c 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -12,7 +12,6 @@ from homeassistant.components.switch import ( SwitchEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,7 +28,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up from config entry.""" - router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] + router = hass.data[DOMAIN].routers[config_entry.unique_id] switches: list[Entity] = [] if router.data.get(KEY_DIALUP_MOBILE_DATASWITCH): diff --git a/homeassistant/components/huawei_lte/translations/ca.json b/homeassistant/components/huawei_lte/translations/ca.json index 92966ca7eeb..0347398ce8b 100644 --- a/homeassistant/components/huawei_lte/translations/ca.json +++ b/homeassistant/components/huawei_lte/translations/ca.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Nom d'usuari" }, - "description": "Introdueix les dades d'acc\u00e9s del dispositiu. El nom d'usuari i contrasenya s\u00f3n opcionals, per\u00f2 habiliten m\u00e9s funcions de la integraci\u00f3. D'altra banda, (mentre la integraci\u00f3 estigui activa) l'\u00fas d'una connexi\u00f3 autoritzada pot causar problemes per accedir a la interf\u00edcie web del dispositiu des de fora de Home Assistant i viceversa.", + "description": "Introdueix les dades d'acc\u00e9s del dispositiu.", "title": "Configuraci\u00f3 de Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nom del servei de notificacions (reinici necessari si canvia)", "recipient": "Destinataris de notificacions SMS", "track_new_devices": "Segueix dispositius nous", - "track_wired_clients": "Segueix els clients connectats a la xarxa per cable" + "track_wired_clients": "Segueix els clients connectats a la xarxa per cable", + "unauthenticated_mode": "Mode no autenticat (canviar requereix tornar a carregar)" } } } diff --git a/homeassistant/components/huawei_lte/translations/de.json b/homeassistant/components/huawei_lte/translations/de.json index a979eeb89fe..a3b40d0c0ae 100644 --- a/homeassistant/components/huawei_lte/translations/de.json +++ b/homeassistant/components/huawei_lte/translations/de.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Benutzername" }, - "description": "Gib die Zugangsdaten zum Ger\u00e4t ein. Die Angabe von Benutzername und Passwort ist optional, erm\u00f6glicht aber die Unterst\u00fctzung weiterer Integrationsfunktionen. Andererseits kann die Verwendung einer autorisierten Verbindung zu Problemen beim Zugriff auf die Web-Schnittstelle des Ger\u00e4ts von au\u00dferhalb des Home Assistant f\u00fchren, w\u00e4hrend die Integration aktiv ist, und umgekehrt.", + "description": "Gib die Zugangsdaten f\u00fcr das Ger\u00e4t ein.", "title": "Konfiguriere Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Name des Benachrichtigungsdienstes (\u00c4nderung erfordert Neustart)", "recipient": "SMS-Benachrichtigungsempf\u00e4nger", "track_new_devices": "Neue Ger\u00e4te verfolgen", - "track_wired_clients": "Kabelgebundene Netzwerk-Clients verfolgen" + "track_wired_clients": "Kabelgebundene Netzwerk-Clients verfolgen", + "unauthenticated_mode": "Nicht authentifizierter Modus (\u00c4nderung erfordert erneutes Laden)" } } } diff --git a/homeassistant/components/huawei_lte/translations/en.json b/homeassistant/components/huawei_lte/translations/en.json index c94791ee592..ade7beed75c 100644 --- a/homeassistant/components/huawei_lte/translations/en.json +++ b/homeassistant/components/huawei_lte/translations/en.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Username" }, - "description": "Enter device access details. Specifying username and password is optional, but enables support for more integration features. On the other hand, use of an authorized connection may cause problems accessing the device web interface from outside Home Assistant while the integration is active, and the other way around.", + "description": "Enter device access details.", "title": "Configure Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Notification service name (change requires restart)", "recipient": "SMS notification recipients", "track_new_devices": "Track new devices", - "track_wired_clients": "Track wired network clients" + "track_wired_clients": "Track wired network clients", + "unauthenticated_mode": "Unauthenticated mode (change requires reload)" } } } diff --git a/homeassistant/components/huawei_lte/translations/es.json b/homeassistant/components/huawei_lte/translations/es.json index 00564d7282a..5d5e72e70c3 100644 --- a/homeassistant/components/huawei_lte/translations/es.json +++ b/homeassistant/components/huawei_lte/translations/es.json @@ -35,7 +35,8 @@ "name": "Nombre del servicio de notificaci\u00f3n (el cambio requiere reiniciar)", "recipient": "Destinatarios de notificaciones por SMS", "track_new_devices": "Rastrea nuevos dispositivos", - "track_wired_clients": "Seguir clientes de red cableados" + "track_wired_clients": "Seguir clientes de red cableados", + "unauthenticated_mode": "Modo no autenticado (el cambio requiere recarga)" } } } diff --git a/homeassistant/components/huawei_lte/translations/et.json b/homeassistant/components/huawei_lte/translations/et.json index 8cd61842767..955d5cc2c5a 100644 --- a/homeassistant/components/huawei_lte/translations/et.json +++ b/homeassistant/components/huawei_lte/translations/et.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Kasutajanimi" }, - "description": "Sisesta seadmele juurdep\u00e4\u00e4su \u00fcksikasjad. Kasutajanime ja salas\u00f5na m\u00e4\u00e4ramine on valikuline kuid v\u00f5imaldab rohkemate sidumisfunktsioonide toetamist. Teisest k\u00fcljest v\u00f5ib volitatud \u00fchenduse kasutamine p\u00f5hjustada probleeme seadme veebiliidese ligip\u00e4\u00e4suga v\u00e4ljastpoolt Home assistant'i kui sidumine on aktiivne ja vastupidi.", + "description": "Sisesta seadmele juurdep\u00e4\u00e4su \u00fcksikasjad.", "title": "Huawei LTE seadistamine" } } @@ -35,7 +35,8 @@ "name": "Teavitusteenuse nimi (muudatus n\u00f5uab taask\u00e4ivitamist)", "recipient": "SMS teavituse saajad", "track_new_devices": "Uute seadmete j\u00e4lgimine", - "track_wired_clients": "J\u00e4lgi juhtmega v\u00f5rgukliente" + "track_wired_clients": "J\u00e4lgi juhtmega v\u00f5rgukliente", + "unauthenticated_mode": "Tuvastuseta re\u017eiim (muutmine n\u00f5uab taaslaadimist)" } } } diff --git a/homeassistant/components/huawei_lte/translations/fr.json b/homeassistant/components/huawei_lte/translations/fr.json index df7e6c2e380..da8fbcbd115 100644 --- a/homeassistant/components/huawei_lte/translations/fr.json +++ b/homeassistant/components/huawei_lte/translations/fr.json @@ -35,7 +35,8 @@ "name": "Nom du service de notification (red\u00e9marrage requis)", "recipient": "Destinataires des notifications SMS", "track_new_devices": "Suivre les nouveaux appareils", - "track_wired_clients": "Suivre les clients du r\u00e9seau filaire" + "track_wired_clients": "Suivre les clients du r\u00e9seau filaire", + "unauthenticated_mode": "Mode non authentifi\u00e9 (le changement n\u00e9cessite un rechargement)" } } } diff --git a/homeassistant/components/huawei_lte/translations/he.json b/homeassistant/components/huawei_lte/translations/he.json index f55b325d867..1e4333a989a 100644 --- a/homeassistant/components/huawei_lte/translations/he.json +++ b/homeassistant/components/huawei_lte/translations/he.json @@ -18,7 +18,7 @@ "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, - "description": "\u05d4\u05d6\u05df \u05e4\u05e8\u05d8\u05d9 \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d4\u05ea\u05e7\u05df. \u05e6\u05d9\u05d5\u05df \u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 \u05d5\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05d5\u05d0 \u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9, \u05d0\u05da \u05de\u05d0\u05e4\u05e9\u05e8 \u05ea\u05de\u05d9\u05db\u05d4 \u05d1\u05ea\u05db\u05d5\u05e0\u05d5\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 \u05e0\u05d5\u05e1\u05e4\u05d5\u05ea. \u05de\u05e6\u05d3 \u05e9\u05e0\u05d9, \u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d7\u05d9\u05d1\u05d5\u05e8 \u05de\u05d5\u05e8\u05e9\u05d4 \u05e2\u05dc\u05d5\u05dc \u05dc\u05d2\u05e8\u05d5\u05dd \u05dc\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05d2\u05d9\u05e9\u05d4 \u05dc\u05de\u05de\u05e9\u05e7 \u05d4\u05d0\u05d9\u05e0\u05d8\u05e8\u05e0\u05d8 \u05e9\u05dc \u05d4\u05d4\u05ea\u05e7\u05df \u05de\u05d7\u05d5\u05e5 \u05dc-Home Assistant \u05d1\u05d6\u05de\u05df \u05e9\u05d4\u05e9\u05d9\u05dc\u05d5\u05d1 \u05e4\u05e2\u05d9\u05dc, \u05d5\u05dc\u05d4\u05d9\u05e4\u05da." + "description": "\u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05e4\u05e8\u05d8\u05d9 \u05d2\u05d9\u05e9\u05d4 \u05dc\u05d4\u05ea\u05e7\u05df." } } } diff --git a/homeassistant/components/huawei_lte/translations/hu.json b/homeassistant/components/huawei_lte/translations/hu.json index 5c045d0c6f3..eff9c8a813b 100644 --- a/homeassistant/components/huawei_lte/translations/hu.json +++ b/homeassistant/components/huawei_lte/translations/hu.json @@ -31,7 +31,9 @@ "data": { "name": "\u00c9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s neve (a m\u00f3dos\u00edt\u00e1s \u00fajraind\u00edt\u00e1st ig\u00e9nyel)", "recipient": "SMS-\u00e9rtes\u00edt\u00e9s c\u00edmzettjei", - "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se" + "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomk\u00f6vet\u00e9se", + "track_wired_clients": "Vezet\u00e9kes h\u00e1l\u00f3zati \u00fcgyfelek nyomon k\u00f6vet\u00e9se", + "unauthenticated_mode": "Nem hiteles\u00edtett m\u00f3d (a v\u00e1ltoztat\u00e1shoz \u00fajrat\u00f6lt\u00e9sre van sz\u00fcks\u00e9g)" } } } diff --git a/homeassistant/components/huawei_lte/translations/it.json b/homeassistant/components/huawei_lte/translations/it.json index df2c6fd441b..ad8d4a82b08 100644 --- a/homeassistant/components/huawei_lte/translations/it.json +++ b/homeassistant/components/huawei_lte/translations/it.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Nome utente" }, - "description": "Immettere i dettagli di accesso al dispositivo. La specifica di nome utente e password \u00e8 facoltativa, ma abilita il supporto per altre funzionalit\u00e0 di integrazione. D'altra parte, l'uso di una connessione autorizzata pu\u00f2 causare problemi di accesso all'interfaccia Web del dispositivo dall'esterno di Home Assistant mentre l'integrazione \u00e8 attiva e viceversa.", + "description": "Inserisci i dettagli di accesso al dispositivo.", "title": "Configura Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nome del servizio di notifica (la modifica richiede il riavvio)", "recipient": "Destinatari della notifica SMS", "track_new_devices": "Traccia nuovi dispositivi", - "track_wired_clients": "Tieni traccia dei client di rete cablata" + "track_wired_clients": "Tieni traccia dei client di rete cablata", + "unauthenticated_mode": "Modalit\u00e0 non autenticata (la modifica richiede il ricaricamento)" } } } diff --git a/homeassistant/components/huawei_lte/translations/nl.json b/homeassistant/components/huawei_lte/translations/nl.json index d851b239436..715efbfd506 100644 --- a/homeassistant/components/huawei_lte/translations/nl.json +++ b/homeassistant/components/huawei_lte/translations/nl.json @@ -35,7 +35,8 @@ "name": "Naam meldingsservice (wijziging vereist opnieuw opstarten)", "recipient": "Ontvangers van sms-berichten", "track_new_devices": "Volg nieuwe apparaten", - "track_wired_clients": "Volg bekabelde netwerkclients" + "track_wired_clients": "Volg bekabelde netwerkclients", + "unauthenticated_mode": "Niet-geverifieerde modus (wijzigen vereist opnieuw laden)" } } } diff --git a/homeassistant/components/huawei_lte/translations/pl.json b/homeassistant/components/huawei_lte/translations/pl.json index efb441f8972..38ee2117b9d 100644 --- a/homeassistant/components/huawei_lte/translations/pl.json +++ b/homeassistant/components/huawei_lte/translations/pl.json @@ -23,7 +23,7 @@ "url": "URL", "username": "Nazwa u\u017cytkownika" }, - "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia. Okre\u015blenie nazwy u\u017cytkownika i has\u0142a jest opcjonalne, ale umo\u017cliwia obs\u0142ug\u0119 wi\u0119kszej liczby funkcji integracji. Z drugiej strony u\u017cycie autoryzowanego po\u0142\u0105czenia mo\u017ce powodowa\u0107 problemy z dost\u0119pem do interfejsu internetowego urz\u0105dzenia z zewn\u0105trz Home Assistanta, gdy integracja jest aktywna.", + "description": "Wprowad\u017a szczeg\u00f3\u0142y dost\u0119pu do urz\u0105dzenia.", "title": "Konfiguracja Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "Nazwa us\u0142ugi powiadomie\u0144 (zmiana wymaga ponownego uruchomienia)", "recipient": "Odbiorcy powiadomie\u0144 SMS", "track_new_devices": "\u015aled\u017a nowe urz\u0105dzenia", - "track_wired_clients": "\u015aled\u017a klient\u00f3w sieci przewodowej" + "track_wired_clients": "\u015aled\u017a klient\u00f3w sieci przewodowej", + "unauthenticated_mode": "Tryb nieuwierzytelniony (zmiana wymaga prze\u0142adowania)" } } } diff --git a/homeassistant/components/huawei_lte/translations/ru.json b/homeassistant/components/huawei_lte/translations/ru.json index 34a00f0523c..e9ddd73191d 100644 --- a/homeassistant/components/huawei_lte/translations/ru.json +++ b/homeassistant/components/huawei_lte/translations/ru.json @@ -23,7 +23,7 @@ "url": "URL-\u0430\u0434\u0440\u0435\u0441", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443. \u0423\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0438\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u043e \u044d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u0438\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438. \u0421 \u0434\u0440\u0443\u0433\u043e\u0439 \u0441\u0442\u043e\u0440\u043e\u043d\u044b, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u043e\u0433\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u043c\u043e\u0436\u0435\u0442 \u0432\u044b\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441 \u0434\u043e\u0441\u0442\u0443\u043f\u043e\u043c \u043a \u0432\u0435\u0431-\u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u0438\u0437 Home Assistant, \u043a\u043e\u0433\u0434\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0430\u043a\u0442\u0438\u0432\u043d\u0430, \u0438 \u043d\u0430\u043e\u0431\u043e\u0440\u043e\u0442.", + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u043b\u044f \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.", "title": "Huawei LTE" } } @@ -35,7 +35,8 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0441\u043b\u0443\u0436\u0431\u044b \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439 (\u043f\u043e\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u043a)", "recipient": "\u041f\u043e\u043b\u0443\u0447\u0430\u0442\u0435\u043b\u0438 SMS-\u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439", "track_new_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430", - "track_wired_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438" + "track_wired_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438", + "unauthenticated_mode": "\u0420\u0435\u0436\u0438\u043c \u0431\u0435\u0437 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 (\u0434\u043b\u044f \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0430)" } } } diff --git a/homeassistant/components/huawei_lte/translations/zh-Hant.json b/homeassistant/components/huawei_lte/translations/zh-Hant.json index 906a4fdc011..0c1d2baa7a9 100644 --- a/homeassistant/components/huawei_lte/translations/zh-Hant.json +++ b/homeassistant/components/huawei_lte/translations/zh-Hant.json @@ -23,7 +23,7 @@ "url": "\u7db2\u5740", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002\u6307\u5b9a\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u70ba\u9078\u9805\u8f38\u5165\uff0c\u4f46\u958b\u555f\u5c07\u652f\u63f4\u66f4\u591a\u6574\u5408\u529f\u80fd\u3002\u6b64\u5916\uff0c\u4f7f\u7528\u6388\u6b0a\u9023\u7dda\uff0c\u53ef\u80fd\u5c0e\u81f4\u6574\u5408\u555f\u7528\u5f8c\uff0c\u7531\u5916\u90e8\u9023\u7dda\u81f3 Home Assistant \u88dd\u7f6e Web \u4ecb\u9762\u51fa\u73fe\u67d0\u4e9b\u554f\u984c\uff0c\u53cd\u4e4b\u4ea6\u7136\u3002", + "description": "\u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u8a73\u7d30\u8cc7\u6599\u3002", "title": "\u8a2d\u5b9a\u83ef\u70ba LTE" } } @@ -35,7 +35,8 @@ "name": "\u901a\u77e5\u670d\u52d9\u540d\u7a31\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09", "recipient": "\u7c21\u8a0a\u901a\u77e5\u6536\u4ef6\u8005", "track_new_devices": "\u8ffd\u8e64\u65b0\u88dd\u7f6e", - "track_wired_clients": "\u8ffd\u8e64\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef" + "track_wired_clients": "\u8ffd\u8e64\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef", + "unauthenticated_mode": "\u672a\u6388\u6b0a\u6a21\u5f0f\uff08\u8b8a\u66f4\u5f8c\u9700\u91cd\u555f\uff09" } } } diff --git a/homeassistant/components/huawei_lte/utils.py b/homeassistant/components/huawei_lte/utils.py new file mode 100644 index 00000000000..69b346a58f4 --- /dev/null +++ b/homeassistant/components/huawei_lte/utils.py @@ -0,0 +1,23 @@ +"""Utilities for the Huawei LTE integration.""" +from __future__ import annotations + +from huawei_lte_api.Connection import GetResponseType + +from homeassistant.helpers.device_registry import format_mac + + +def get_device_macs( + device_info: GetResponseType, wlan_settings: GetResponseType +) -> list[str]: + """Get list of device MAC addresses. + + :param device_info: the device.information structure for the device + :param wlan_settings: the wlan.multi_basic_settings structure for the device + """ + macs = [device_info.get("MacAddress1"), device_info.get("MacAddress2")] + try: + macs.extend(x.get("WifiMac") for x in wlan_settings["Ssids"]["Ssid"]) + except Exception: # pylint: disable=broad-except + # Assume not supported + pass + return sorted({format_mac(str(x)) for x in macs if x}) diff --git a/homeassistant/components/hue/hue_event.py b/homeassistant/components/hue/hue_event.py index 7c0163f8a16..6bd68b106bb 100644 --- a/homeassistant/components/hue/hue_event.py +++ b/homeassistant/components/hue/hue_event.py @@ -1,7 +1,12 @@ """Representation of a Hue remote firing events for button presses.""" import logging -from aiohue.sensors import TYPE_ZGP_SWITCH, TYPE_ZLL_ROTARY, TYPE_ZLL_SWITCH +from aiohue.sensors import ( + EVENT_BUTTON, + TYPE_ZGP_SWITCH, + TYPE_ZLL_ROTARY, + TYPE_ZLL_SWITCH, +) from homeassistant.const import CONF_EVENT, CONF_ID, CONF_UNIQUE_ID from homeassistant.core import callback @@ -50,6 +55,11 @@ class HueEvent(GenericHueDevice): """Fire the event if reason is that state is updated.""" if ( self.sensor.state == self._last_state + # Filter out non-button events if last event type is available + or ( + self.sensor.last_event is not None + and self.sensor.last_event["type"] != EVENT_BUTTON + ) or # Filter out old states. Can happen when events fire while refreshing dt_util.parse_datetime(self.sensor.state["lastupdated"]) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index 3c8078364ab..32b3cd4ee51 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==2.5.1"], + "requirements": ["aiohue==2.6.1"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 824f8cf42dc..957565c54e9 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -160,8 +160,8 @@ class SensorManager: ) ) - for platform in to_add: - self._component_add_entities[platform](to_add[platform]) + for platform, value in to_add.items(): + self._component_add_entities[platform](value) class GenericHueSensor(GenericHueDevice, entity.Entity): diff --git a/homeassistant/components/hue/translations/ar.json b/homeassistant/components/hue/translations/ar.json new file mode 100644 index 00000000000..785292b5729 --- /dev/null +++ b/homeassistant/components/hue/translations/ar.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "all_configured": "\u062a\u0645 \u062a\u0643\u0648\u064a\u0646 \u062c\u0645\u064a\u0639 \u062c\u0633\u0648\u0631 Philips Hue \u0645\u0633\u0628\u0642\u0627", + "cannot_connect": "\u0641\u0634\u0644 \u0641\u064a \u0627\u0644\u0627\u062a\u0635\u0627\u0644", + "discover_timeout": "\u063a\u064a\u0631 \u0642\u0627\u062f\u0631 \u0639\u0644\u0649 \u0627\u0643\u062a\u0634\u0627\u0641 \u062c\u0633\u0648\u0631 Hue", + "no_bridges": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641 \u062c\u0633\u0648\u0631 Philips Hue", + "unknown": "\u062d\u062f\u062b \u062e\u0637\u0623 \u063a\u064a\u0631 \u0645\u062a\u0648\u0642\u0639" + }, + "error": { + "linking": "\u062d\u062f\u062b \u062e\u0637\u0623 \u063a\u064a\u0631 \u0645\u062a\u0648\u0642\u0639", + "register_failed": "\u0641\u0634\u0644 \u0627\u0644\u062a\u0633\u062c\u064a\u0644 \u060c \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649" + }, + "step": { + "link": { + "description": "\u0627\u0636\u063a\u0637 \u0639\u0644\u0649 \u0627\u0644\u0632\u0631 \u0627\u0644\u0645\u0648\u062c\u0648\u062f \u0639\u0644\u0649 \u0627\u0644\u062c\u0633\u0631 \u0644\u062a\u0633\u062c\u064a\u0644 Philips Hue \u0645\u0639 Home Assistant. \n\n ! [\u0645\u0648\u0642\u0639 \u0627\u0644\u0632\u0631 \u0639\u0644\u0649 \u0627\u0644\u062c\u0633\u0631] (/static/images/config_philips_hue.jpg)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/de.json b/homeassistant/components/hue/translations/de.json index 122e1ba6f5c..bf0d2a7c756 100644 --- a/homeassistant/components/hue/translations/de.json +++ b/homeassistant/components/hue/translations/de.json @@ -8,7 +8,7 @@ "discover_timeout": "Es k\u00f6nnen keine Hue Bridges erkannt werden", "no_bridges": "Keine Philips Hue Bridges erkannt", "not_hue_bridge": "Keine Philips Hue Bridge entdeckt", - "unknown": "Unbekannter Fehler ist aufgetreten" + "unknown": "Unerwarteter Fehler" }, "error": { "linking": "Unerwarteter Fehler", diff --git a/homeassistant/components/hue/translations/fr.json b/homeassistant/components/hue/translations/fr.json index f19c5ec7a34..e9dd546840f 100644 --- a/homeassistant/components/hue/translations/fr.json +++ b/homeassistant/components/hue/translations/fr.json @@ -11,13 +11,13 @@ "unknown": "Une erreur inconnue s'est produite" }, "error": { - "linking": "Une erreur inconnue s'est produite lors de la liaison entre le pont et Home Assistant", + "linking": "Erreur inattendue", "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer." }, "step": { "init": { "data": { - "host": "Nom d'h\u00f4te ou adresse IP" + "host": "H\u00f4te" }, "title": "Choisissez le pont Philips Hue" }, diff --git a/homeassistant/components/hue/translations/he.json b/homeassistant/components/hue/translations/he.json index c014b0a52ae..ece439b376b 100644 --- a/homeassistant/components/hue/translations/he.json +++ b/homeassistant/components/hue/translations/he.json @@ -2,15 +2,15 @@ "config": { "abort": { "all_configured": "\u05db\u05dc \u05d4\u05de\u05d2\u05e9\u05e8\u05d9\u05dd \u05e9\u05dc Philips Hue \u05de\u05d5\u05d2\u05d3\u05e8\u05d9\u05dd \u05db\u05d1\u05e8", - "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", - "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05d2\u05e9\u05e8", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "discover_timeout": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d2\u05dc\u05d5\u05ea \u05de\u05d2\u05e9\u05e8\u05d9\u05dd", "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 Philips Hue", - "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4." + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { - "linking": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4.", + "linking": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4", "register_failed": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1." }, "step": { diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 038325ece4a..3cda3cdec00 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -65,7 +65,7 @@ class HuisbaasjeSensor(CoordinatorEntity, SensorEntity): return self._name @property - def device_class(self) -> str: + def device_class(self) -> str | None: """Return the device class of the sensor.""" return self._device_class diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 7839eeec799..d15f70f181a 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -1,6 +1,7 @@ """Provides functionality to interact with humidifier devices.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -21,7 +22,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -101,9 +102,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class HumidifierEntityDescription(ToggleEntityDescription): + """A class that describes humidifier entities.""" + + class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" + entity_description: HumidifierEntityDescription _attr_available_modes: list[str] | None _attr_max_humidity: int = DEFAULT_MAX_HUMIDITY _attr_min_humidity: int = DEFAULT_MIN_HUMIDITY diff --git a/homeassistant/components/hunterdouglas_powerview/translations/de.json b/homeassistant/components/hunterdouglas_powerview/translations/de.json index db0fa18cc29..591e4ee8924 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/de.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/de.json @@ -10,14 +10,14 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "M\u00f6chten Sie {name} ({host}) einrichten?", - "title": "Stellen Sie eine Verbindung zum PowerView Hub her" + "description": "M\u00f6chtest du {name} ({host}) einrichten?", + "title": "Stelle eine Verbindung zum PowerView Hub her" }, "user": { "data": { "host": "IP-Adresse" }, - "title": "Stellen Sie eine Verbindung zum PowerView Hub her" + "title": "Stelle eine Verbindung zum PowerView Hub her" } } } diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index a1bd06078c6..68ea30b293f 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -7,6 +7,7 @@ "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", "unknown": "Erreur inattendue" }, + "flow_title": "{name} ({host})", "step": { "link": { "description": "Voulez-vous configurer {name} ({host})?", diff --git a/homeassistant/components/hunterdouglas_powerview/translations/he.json b/homeassistant/components/hunterdouglas_powerview/translations/he.json index c6610f79e77..8bd6c87154c 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/he.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/he.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" }, "user": { "data": { diff --git a/homeassistant/components/hunterdouglas_powerview/translations/id.json b/homeassistant/components/hunterdouglas_powerview/translations/id.json index 2d21f87bf67..273e2badd53 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/id.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/id.json @@ -7,6 +7,7 @@ "cannot_connect": "Gagal terhubung", "unknown": "Kesalahan yang tidak diharapkan" }, + "flow_title": "{name} ({host})", "step": { "link": { "description": "Ingin menyiapkan {name} ({host})?", diff --git a/homeassistant/components/hvv_departures/translations/hu.json b/homeassistant/components/hvv_departures/translations/hu.json index 91da2d13a7c..deab9bcb929 100644 --- a/homeassistant/components/hvv_departures/translations/hu.json +++ b/homeassistant/components/hvv_departures/translations/hu.json @@ -20,6 +20,9 @@ "options": { "step": { "init": { + "data": { + "offset": "Eltol\u00e1s (perc)" + }, "title": "Be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 891f48e8738..36185c68758 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -273,10 +273,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def setup_then_listen() -> None: await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) assert hyperion_client if hyperion_client.instances is not None: @@ -306,12 +306,12 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Disconnect the shared instance clients. await asyncio.gather( - *[ + *( config_data[CONF_INSTANCE_CLIENTS][ instance_num ].async_client_disconnect() for instance_num in config_data[CONF_INSTANCE_CLIENTS] - ] + ) ) # Disconnect the root client. diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 1ef88969228..22134400a45 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -27,14 +27,13 @@ from homeassistant.components.camera import ( async_get_still_stream, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from . import ( get_hyperion_device_id, @@ -57,7 +56,7 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64," async def async_setup_entry( - hass: HomeAssistantType, + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 3e82f796c0e..81fef6429f6 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -28,7 +28,6 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType from . import create_hyperion_client from .const import ( @@ -143,7 +142,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth( self, - config_data: ConfigType, + config_data: dict[str, Any], ) -> FlowResult: """Handle a reauthentication flow.""" self._data = dict(config_data) @@ -222,7 +221,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, - user_input: ConfigType | None = None, + user_input: dict[str, Any] | None = None, ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} @@ -293,7 +292,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_auth( self, - user_input: ConfigType | None = None, + user_input: dict[str, Any] | None = None, ) -> FlowResult: """Handle the auth step of a flow.""" errors = {} @@ -322,7 +321,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_create_token( - self, user_input: ConfigType | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Send a request for a new token.""" if user_input is None: @@ -348,7 +347,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): ) async def async_step_create_token_external( - self, auth_resp: ConfigType | None = None + self, auth_resp: dict[str, Any] | None = None ) -> FlowResult: """Handle completion of the request for a new token.""" if auth_resp is not None and client.ResponseOK(auth_resp): @@ -361,7 +360,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_external_step_done(next_step_id="create_token_fail") async def async_step_create_token_success( - self, _: ConfigType | None = None + self, _: dict[str, Any] | None = None ) -> FlowResult: """Create an entry after successful token creation.""" # Clean-up the request task. @@ -377,7 +376,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() async def async_step_create_token_fail( - self, _: ConfigType | None = None + self, _: dict[str, Any] | None = None ) -> FlowResult: """Show an error on the auth form.""" # Clean-up the request task. @@ -385,7 +384,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="auth_new_token_not_granted_error") async def async_step_confirm( - self, user_input: ConfigType | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Get final confirmation before entry creation.""" if user_input is None and self._require_confirm: diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 63e90a89068..e9d23b4077e 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -527,10 +527,10 @@ class HyperionLight(HyperionBaseLight): # color, effect), but this is not possible due to: # https://github.com/hyperion-project/hyperion.ng/issues/967 if not bool(self._client.is_on()): - for component in [ + for component in ( const.KEY_COMPONENTID_ALL, const.KEY_COMPONENTID_LEDDEVICE, - ]: + ): if not await self._client.async_send_set_component( **{ const.KEY_COMPONENTSTATE: { diff --git a/homeassistant/components/hyperion/translations/ar.json b/homeassistant/components/hyperion/translations/ar.json new file mode 100644 index 00000000000..9a017f7b2b2 --- /dev/null +++ b/homeassistant/components/hyperion/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0625\u0636\u0627\u0641\u0629 Hyperion Ambilight \u0625\u0644\u0649 Home Assistant\u061f \n\n ** \u0627\u0644\u0645\u0636\u064a\u0641: ** {host}\n ** \u0627\u0644\u0645\u0646\u0641\u0630: ** {port}\n ** \u0627\u0644\u0645\u0639\u0631\u0641 **: {id}", + "title": "\u062a\u0623\u0643\u064a\u062f \u0625\u0636\u0627\u0641\u0629 \u062e\u062f\u0645\u0629 Hyperion Ambilight" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hyperion/translations/hu.json b/homeassistant/components/hyperion/translations/hu.json index 5096423c143..852c108c0e9 100644 --- a/homeassistant/components/hyperion/translations/hu.json +++ b/homeassistant/components/hyperion/translations/hu.json @@ -3,7 +3,11 @@ "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "auth_new_token_not_granted_error": "Az \u00fajonnan l\u00e9trehozott tokent nem hagyt\u00e1k j\u00f3v\u00e1 a Hyperion felhaszn\u00e1l\u00f3i fel\u00fclet\u00e9n", + "auth_new_token_not_work_error": "Nem siker\u00fclt hiteles\u00edteni az \u00fajonnan l\u00e9trehozott token haszn\u00e1lat\u00e1val", + "auth_required_error": "Nem siker\u00fclt meghat\u00e1rozni, hogy sz\u00fcks\u00e9ges-e enged\u00e9ly", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "no_id": "A Hyperion Ambilight p\u00e9ld\u00e1ny nem szolg\u00e1ltatja az azonos\u00edt\u00f3j\u00e1t", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { @@ -13,8 +17,21 @@ "step": { "auth": { "data": { - "create_token": "\u00daj token automatikus l\u00e9trehoz\u00e1sa" - } + "create_token": "\u00daj token automatikus l\u00e9trehoz\u00e1sa", + "token": "Vagy adjon meg m\u00e1r l\u00e9tez\u0151 tokent" + }, + "description": "Konfigur\u00e1lja a jogosults\u00e1got a Hyperion Ambilight kiszolg\u00e1l\u00f3hoz" + }, + "confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni ezt a Hyperion Ambilight-ot az Otthoni asszisztenshez? \n\n ** Host: ** {host}\n ** Port: ** {port}\n ** azonos\u00edt\u00f3 **: {id}", + "title": "Er\u0151s\u00edtse meg a Hyperion Ambilight szolg\u00e1ltat\u00e1s hozz\u00e1ad\u00e1s\u00e1t" + }, + "create_token": { + "description": "Az al\u00e1bbiakban v\u00e1lassza a ** K\u00fcld\u00e9s ** lehet\u0151s\u00e9get \u00faj hiteles\u00edt\u00e9si token k\u00e9r\u00e9s\u00e9hez. A k\u00e9relem j\u00f3v\u00e1hagy\u00e1s\u00e1hoz \u00e1tir\u00e1ny\u00edtunk a Hyperion felhaszn\u00e1l\u00f3i fel\u00fcletre. K\u00e9rj\u00fck, ellen\u0151rizze, hogy a megjelen\u00edtett azonos\u00edt\u00f3 \" {auth_id} \"", + "title": "\u00daj hiteles\u00edt\u00e9si token automatikus l\u00e9trehoz\u00e1sa" + }, + "create_token_external": { + "title": "\u00daj token elfogad\u00e1sa a Hyperion felhaszn\u00e1l\u00f3i fel\u00fcleten" }, "user": { "data": { @@ -28,7 +45,8 @@ "step": { "init": { "data": { - "effect_show_list": "Megjelen\u00edtend\u0151 Hyperion effektusok" + "effect_show_list": "Megjelen\u00edtend\u0151 Hyperion effektusok", + "priority": "Hyperion priorit\u00e1s a sz\u00ednekhez \u00e9s effektekhez" } } } diff --git a/homeassistant/components/ialarm/translations/hu.json b/homeassistant/components/ialarm/translations/hu.json new file mode 100644 index 00000000000..e69c6e7e7ea --- /dev/null +++ b/homeassistant/components/ialarm/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index 96c82cd2c76..5380a97901e 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -1,6 +1,8 @@ """Config flow to configure zone component.""" from __future__ import annotations +from typing import Any + from iaqualink import AqualinkClient, AqualinkLoginException import voluptuous as vol @@ -16,7 +18,7 @@ class AqualinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: ConfigType | None = None): + async def async_step_user(self, user_input: dict[str, Any] | None = None): """Handle a flow start.""" # Supporting a single account. entries = self._async_current_entries() diff --git a/homeassistant/components/icloud/translations/de.json b/homeassistant/components/icloud/translations/de.json index 7baf9dc3917..207735018f0 100644 --- a/homeassistant/components/icloud/translations/de.json +++ b/homeassistant/components/icloud/translations/de.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", - "no_device": "Auf keinem Ihrer Ger\u00e4te ist \"Find my iPhone\" aktiviert", + "no_device": "Auf keinem deiner Ger\u00e4te ist \"Find my iPhone\" aktiviert", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { @@ -15,7 +15,7 @@ "data": { "password": "Passwort" }, - "description": "Ihr zuvor eingegebenes Passwort f\u00fcr {username} funktioniert nicht mehr. Aktualisieren Sie Ihr Passwort, um diese Integration weiterhin zu verwenden.", + "description": "Dein zuvor eingegebenes Passwort f\u00fcr {username} funktioniert nicht mehr. Aktualisiere dein Passwort, um diese Integration weiterhin zu verwenden.", "title": "Integration erneut authentifizieren" }, "trusted_device": { @@ -28,7 +28,7 @@ "user": { "data": { "password": "Passwort", - "username": "Email", + "username": "E-Mail", "with_family": "Mit Familie" }, "description": "Gib deine Zugangsdaten ein", diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index 3348e857f51..d1aec781df7 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -1,6 +1,7 @@ """Support for IHC sensors.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_UNIT_OF_MEASUREMENT +from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, DEVICE_CLASS_TEMPERATURE +from homeassistant.util.unit_system import TEMPERATURE_UNITS from . import IHC_CONTROLLER, IHC_INFO from .ihcdevice import IHCDevice @@ -37,6 +38,15 @@ class IHCSensor(IHCDevice, SensorEntity): self._state = None self._unit_of_measurement = unit + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return ( + DEVICE_CLASS_TEMPERATURE + if self._unit_of_measurement in TEMPERATURE_UNITS + else None + ) + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 58a6582e33c..d1220a84cdd 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -164,7 +164,7 @@ class ImageProcessingFaceEntity(ImageProcessingEntity): f_co = face[ATTR_CONFIDENCE] if f_co > confidence: confidence = f_co - for attr in [ATTR_NAME, ATTR_MOTION]: + for attr in (ATTR_NAME, ATTR_MOTION): if attr in face: state = face[attr] break diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index cea7244919b..9d14703fa32 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -53,7 +53,7 @@ async def async_setup(hass, hass_config): for heater in heaters: await heater.update() - for platform in ["water_heater", "binary_sensor", "sensor", "climate"]: + for platform in ("water_heater", "binary_sensor", "sensor", "climate"): hass.async_create_task( async_load_platform(hass, platform, DOMAIN, {}, hass_config) ) diff --git a/homeassistant/components/influxdb/const.py b/homeassistant/components/influxdb/const.py index e66a0fe10c4..77f76745ba4 100644 --- a/homeassistant/components/influxdb/const.py +++ b/homeassistant/components/influxdb/const.py @@ -84,7 +84,7 @@ TEST_QUERY_V1 = "SHOW DATABASES;" TEST_QUERY_V2 = "buckets()" CODE_INVALID_INPUTS = 400 -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) RE_DIGIT_TAIL = re.compile(r"^[^\.]*\d+\.?\d+[^\.]*$") RE_DECIMAL = re.compile(r"[^\d.]+") diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index 299fc595f4b..c2cb5070a4c 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -1,7 +1,9 @@ """InfluxDB component which allows you to get data from an Influx database.""" from __future__ import annotations +import datetime import logging +from typing import Final import voluptuous as vol @@ -62,6 +64,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL: Final = datetime.timedelta(seconds=60) + def _merge_connection_config_into_query(conf, query): """Merge connection details into each configured query.""" diff --git a/homeassistant/components/input_boolean/translations/he.json b/homeassistant/components/input_boolean/translations/he.json index 08bdc30a602..b5d50c10627 100644 --- a/homeassistant/components/input_boolean/translations/he.json +++ b/homeassistant/components/input_boolean/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05e7\u05dc\u05d8 \u05d1\u05d5\u05dc\u05d9\u05d0\u05e0\u05d9" diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 2e2d801e1f2..223448953b9 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -9,6 +9,7 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PLATFORM, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady +from . import api from .const import ( CONF_CAT, CONF_DIM_STEPS, @@ -164,6 +165,8 @@ async def async_setup_entry(hass, entry): sw_version=f"{devices.modem.firmware:02x} Engine Version: {devices.modem.engine_version}", ) + api.async_load_api(hass) + asyncio.create_task(async_get_device_config(hass, entry)) return True diff --git a/homeassistant/components/insteon/api/__init__.py b/homeassistant/components/insteon/api/__init__.py new file mode 100644 index 00000000000..3b786a38343 --- /dev/null +++ b/homeassistant/components/insteon/api/__init__.py @@ -0,0 +1,44 @@ +"""Insteon API interface for the frontend.""" + +from homeassistant.components import websocket_api +from homeassistant.core import callback + +from .aldb import ( + websocket_add_default_links, + websocket_change_aldb_record, + websocket_create_aldb_record, + websocket_get_aldb, + websocket_load_aldb, + websocket_notify_on_aldb_status, + websocket_reset_aldb, + websocket_write_aldb, +) +from .device import websocket_get_device +from .properties import ( + websocket_change_properties_record, + websocket_get_properties, + websocket_load_properties, + websocket_reset_properties, + websocket_write_properties, +) + + +@callback +def async_load_api(hass): + """Set up the web socket API.""" + websocket_api.async_register_command(hass, websocket_get_device) + + websocket_api.async_register_command(hass, websocket_get_aldb) + websocket_api.async_register_command(hass, websocket_change_aldb_record) + websocket_api.async_register_command(hass, websocket_create_aldb_record) + websocket_api.async_register_command(hass, websocket_write_aldb) + websocket_api.async_register_command(hass, websocket_load_aldb) + websocket_api.async_register_command(hass, websocket_reset_aldb) + websocket_api.async_register_command(hass, websocket_add_default_links) + websocket_api.async_register_command(hass, websocket_notify_on_aldb_status) + + websocket_api.async_register_command(hass, websocket_get_properties) + websocket_api.async_register_command(hass, websocket_change_properties_record) + websocket_api.async_register_command(hass, websocket_write_properties) + websocket_api.async_register_command(hass, websocket_load_properties) + websocket_api.async_register_command(hass, websocket_reset_properties) diff --git a/homeassistant/components/insteon/api/aldb.py b/homeassistant/components/insteon/api/aldb.py new file mode 100644 index 00000000000..881cb0bb8c7 --- /dev/null +++ b/homeassistant/components/insteon/api/aldb.py @@ -0,0 +1,309 @@ +"""Web socket API for Insteon devices.""" + +from pyinsteon import devices +from pyinsteon.constants import ALDBStatus +from pyinsteon.topics import ( + ALDB_STATUS_CHANGED, + DEVICE_LINK_CONTROLLER_CREATED, + DEVICE_LINK_RESPONDER_CREATED, +) +from pyinsteon.utils import subscribe_topic, unsubscribe_topic +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from ..const import DEVICE_ADDRESS, ID, INSTEON_DEVICE_NOT_FOUND, TYPE +from .device import async_device_name, notify_device_not_found + +ALDB_RECORD = "record" +ALDB_RECORD_SCHEMA = vol.Schema( + { + vol.Required("mem_addr"): int, + vol.Required("in_use"): bool, + vol.Required("group"): vol.Range(0, 255), + vol.Required("is_controller"): bool, + vol.Optional("highwater"): bool, + vol.Required("target"): str, + vol.Optional("target_name"): str, + vol.Required("data1"): vol.Range(0, 255), + vol.Required("data2"): vol.Range(0, 255), + vol.Required("data3"): vol.Range(0, 255), + vol.Optional("dirty"): bool, + } +) + + +async def async_aldb_record_to_dict(dev_registry, record, dirty=False): + """Convert an ALDB record to a dict.""" + return ALDB_RECORD_SCHEMA( + { + "mem_addr": record.mem_addr, + "in_use": record.is_in_use, + "is_controller": record.is_controller, + "highwater": record.is_high_water_mark, + "group": record.group, + "target": str(record.target), + "target_name": await async_device_name(dev_registry, record.target), + "data1": record.data1, + "data2": record.data2, + "data3": record.data3, + "dirty": dirty, + } + ) + + +async def async_reload_and_save_aldb(hass, device): + """Add default links to an Insteon device.""" + if device == devices.modem: + await device.aldb.async_load() + else: + await device.aldb.async_load(refresh=True) + await devices.async_save(workdir=hass.config.config_dir) + + +@websocket_api.websocket_command( + {vol.Required(TYPE): "insteon/aldb/get", vol.Required(DEVICE_ADDRESS): str} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get the All-Link Database for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + # Convert the ALDB to a dict merge in pending changes + aldb = {mem_addr: device.aldb[mem_addr] for mem_addr in device.aldb} + aldb.update(device.aldb.pending_changes) + changed_records = list(device.aldb.pending_changes.keys()) + + dev_registry = await hass.helpers.device_registry.async_get_registry() + + records = [ + await async_aldb_record_to_dict( + dev_registry, aldb[mem_addr], mem_addr in changed_records + ) + for mem_addr in aldb + ] + + connection.send_result(msg[ID], records) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/change", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(ALDB_RECORD): ALDB_RECORD_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_change_aldb_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Change an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + record = msg[ALDB_RECORD] + device.aldb.modify( + mem_addr=record["mem_addr"], + in_use=record["in_use"], + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/create", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(ALDB_RECORD): ALDB_RECORD_SCHEMA, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_create_aldb_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + record = msg[ALDB_RECORD] + device.aldb.add( + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/write", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_write_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + await device.aldb.async_write() + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/load", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_load_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/reset", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_reset_aldb( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Create an All-Link Database record for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + device.aldb.clear_pending() + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/add_default_links", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_add_default_links( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + device.aldb.clear_pending() + await device.async_add_default_links() + hass.async_create_task(async_reload_and_save_aldb(hass, device)) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/aldb/notify", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_notify_on_aldb_status( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Tell Insteon a new ALDB record was added.""" + + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + @callback + def record_added(controller, responder, group): + """Forward ALDB events to websocket.""" + forward_data = {"type": "record_loaded"} + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + @callback + def aldb_loaded(): + """Forward ALDB loaded event to websocket.""" + forward_data = { + "type": "status_changed", + "is_loading": device.aldb.status == ALDBStatus.LOADING, + } + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + unsubscribe_topic(record_added, f"{DEVICE_LINK_CONTROLLER_CREATED}.{device.id}") + unsubscribe_topic(record_added, f"{DEVICE_LINK_RESPONDER_CREATED}.{device.id}") + unsubscribe_topic(aldb_loaded, f"{device.id}.{ALDB_STATUS_CHANGED}") + + forward_data = {"type": "unsubscribed"} + connection.send_message(websocket_api.event_message(msg["id"], forward_data)) + + connection.subscriptions[msg["id"]] = async_cleanup + subscribe_topic(record_added, f"{DEVICE_LINK_CONTROLLER_CREATED}.{device.id}") + subscribe_topic(record_added, f"{DEVICE_LINK_RESPONDER_CREATED}.{device.id}") + subscribe_topic(aldb_loaded, f"{device.id}.{ALDB_STATUS_CHANGED}") + + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/api/device.py b/homeassistant/components/insteon/api/device.py new file mode 100644 index 00000000000..9d77e8b765c --- /dev/null +++ b/homeassistant/components/insteon/api/device.py @@ -0,0 +1,79 @@ +"""API interface to get an Insteon device.""" + +from pyinsteon import devices +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant + +from ..const import ( + DEVICE_ID, + DOMAIN, + HA_DEVICE_NOT_FOUND, + ID, + INSTEON_DEVICE_NOT_FOUND, + TYPE, +) + + +def compute_device_name(ha_device): + """Return the HA device name.""" + return ha_device.name_by_user if ha_device.name_by_user else ha_device.name + + +def get_insteon_device_from_ha_device(ha_device): + """Return the Insteon device from an HA device.""" + for identifier in ha_device.identifiers: + if len(identifier) > 1 and identifier[0] == DOMAIN and devices[identifier[1]]: + return devices[identifier[1]] + return None + + +async def async_device_name(dev_registry, address): + """Get the Insteon device name from a device registry id.""" + ha_device = dev_registry.async_get_device( + identifiers={(DOMAIN, str(address))}, connections=set() + ) + if not ha_device: + device = devices[address] + if device: + return f"{device.description} ({device.model})" + return "" + return compute_device_name(ha_device) + + +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.websocket_command( + {vol.Required(TYPE): "insteon/device/get", vol.Required(DEVICE_ID): str} +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_device( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Get an Insteon device.""" + dev_registry = await hass.helpers.device_registry.async_get_registry() + ha_device = dev_registry.async_get(msg[DEVICE_ID]) + if not ha_device: + notify_device_not_found(connection, msg, HA_DEVICE_NOT_FOUND) + return + device = get_insteon_device_from_ha_device(ha_device) + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + ha_name = compute_device_name(ha_device) + device_info = { + "name": ha_name, + "address": str(device.address), + "is_battery": device.is_battery, + "aldb_status": str(device.aldb.status), + } + connection.send_result(msg[ID], device_info) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py new file mode 100644 index 00000000000..0b3b643b617 --- /dev/null +++ b/homeassistant/components/insteon/api/properties.py @@ -0,0 +1,420 @@ +"""Property update methods and schemas.""" +from itertools import chain + +from pyinsteon import devices +from pyinsteon.constants import RAMP_RATES, ResponseStatus +from pyinsteon.device_types.device_base import Device +from pyinsteon.extended_property import ( + NON_TOGGLE_MASK, + NON_TOGGLE_ON_OFF_MASK, + OFF_MASK, + ON_MASK, + RAMP_RATE, +) +from pyinsteon.utils import ramp_rate_to_seconds, seconds_to_ramp_rate +import voluptuous as vol +import voluptuous_serialize + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant +import homeassistant.helpers.config_validation as cv + +from ..const import ( + DEVICE_ADDRESS, + ID, + INSTEON_DEVICE_NOT_FOUND, + PROPERTY_NAME, + PROPERTY_VALUE, + TYPE, +) +from .device import notify_device_not_found + +TOGGLE_ON_OFF_MODE = "toggle_on_off_mode" +NON_TOGGLE_ON_MODE = "non_toggle_on_mode" +NON_TOGGLE_OFF_MODE = "non_toggle_off_mode" +RADIO_BUTTON_GROUP_PROP = "radio_button_group_" +TOGGLE_PROP = "toggle_" +RAMP_RATE_SECONDS = list(dict.fromkeys(RAMP_RATES.values())) +RAMP_RATE_SECONDS.sort() +TOGGLE_MODES = {TOGGLE_ON_OFF_MODE: 0, NON_TOGGLE_ON_MODE: 1, NON_TOGGLE_OFF_MODE: 2} +TOGGLE_MODES_SCHEMA = { + 0: TOGGLE_ON_OFF_MODE, + 1: NON_TOGGLE_ON_MODE, + 2: NON_TOGGLE_OFF_MODE, +} + + +def _bool_schema(name): + return voluptuous_serialize.convert(vol.Schema({vol.Required(name): bool}))[0] + + +def _byte_schema(name): + return voluptuous_serialize.convert(vol.Schema({vol.Required(name): cv.byte}))[0] + + +def _ramp_rate_schema(name): + return voluptuous_serialize.convert( + vol.Schema({vol.Required(name): vol.In(RAMP_RATE_SECONDS)}), + custom_serializer=cv.custom_serializer, + )[0] + + +def get_properties(device: Device): + """Get the properties of an Insteon device and return the records and schema.""" + + properties = [] + schema = {} + + # Limit the properties we manage at this time. + for prop_name in device.operating_flags: + if not device.operating_flags[prop_name].is_read_only: + prop_dict, schema_dict = _get_property(device.operating_flags[prop_name]) + properties.append(prop_dict) + schema[prop_name] = schema_dict + + mask_found = False + for prop_name in device.properties: + if device.properties[prop_name].is_read_only: + continue + + if prop_name == RAMP_RATE: + rr_prop, rr_schema = _get_ramp_rate_property(device.properties[prop_name]) + properties.append(rr_prop) + schema[RAMP_RATE] = rr_schema + + elif not mask_found and "mask" in prop_name: + mask_found = True + toggle_props, toggle_schema = _get_toggle_properties(device) + properties.extend(toggle_props) + schema.update(toggle_schema) + + rb_props, rb_schema = _get_radio_button_properties(device) + properties.extend(rb_props) + schema.update(rb_schema) + else: + prop_dict, schema_dict = _get_property(device.properties[prop_name]) + properties.append(prop_dict) + schema[prop_name] = schema_dict + + return properties, schema + + +def set_property(device, prop_name: str, value): + """Update a property value.""" + if isinstance(value, bool) and prop_name in device.operating_flags: + device.operating_flags[prop_name].new_value = value + + elif prop_name == RAMP_RATE: + device.properties[prop_name].new_value = seconds_to_ramp_rate(value) + + elif prop_name.startswith(RADIO_BUTTON_GROUP_PROP): + buttons = [int(button) for button in value] + rb_groups = _calc_radio_button_groups(device) + curr_group = int(prop_name[len(RADIO_BUTTON_GROUP_PROP) :]) + if len(rb_groups) > curr_group: + removed = [btn for btn in rb_groups[curr_group] if btn not in buttons] + if removed: + device.clear_radio_buttons(removed) + if buttons: + device.set_radio_buttons(buttons) + + elif prop_name.startswith(TOGGLE_PROP): + button_name = prop_name[len(TOGGLE_PROP) :] + for button in device.groups: + if device.groups[button].name == button_name: + device.set_toggle_mode(button, int(value)) + + else: + device.properties[prop_name].new_value = value + + +def _get_property(prop): + """Return a property data row.""" + value, modified = _get_usable_value(prop) + prop_dict = {"name": prop.name, "value": value, "modified": modified} + if isinstance(prop.value, bool): + schema = _bool_schema(prop.name) + else: + schema = _byte_schema(prop.name) + return prop_dict, {"name": prop.name, **schema} + + +def _get_toggle_properties(device): + """Generate the mask properties for a KPL device.""" + props = [] + schema = {} + toggle_prop = device.properties[NON_TOGGLE_MASK] + toggle_on_prop = device.properties[NON_TOGGLE_ON_OFF_MASK] + for button in device.groups: + name = f"{TOGGLE_PROP}{device.groups[button].name}" + value, modified = _toggle_button_value(toggle_prop, toggle_on_prop, button) + props.append({"name": name, "value": value, "modified": modified}) + toggle_schema = vol.Schema({vol.Required(name): vol.In(TOGGLE_MODES_SCHEMA)}) + toggle_schema_dict = voluptuous_serialize.convert( + toggle_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = toggle_schema_dict[0] + return props, schema + + +def _toggle_button_value(non_toggle_prop, toggle_on_prop, button): + """Determine the toggle value of a button.""" + toggle_mask, toggle_modified = _get_usable_value(non_toggle_prop) + toggle_on_mask, toggle_on_modified = _get_usable_value(toggle_on_prop) + + bit = button - 1 + if not toggle_mask & 1 << bit: + value = 0 + else: + if toggle_on_mask & 1 << bit: + value = 1 + else: + value = 2 + + modified = False + if toggle_modified: + curr_bit = non_toggle_prop.value & 1 << bit + new_bit = non_toggle_prop.new_value & 1 << bit + modified = not curr_bit == new_bit + + if not modified and value != 0 and toggle_on_modified: + curr_bit = toggle_on_prop.value & 1 << bit + new_bit = toggle_on_prop.new_value & 1 << bit + modified = not curr_bit == new_bit + + return value, modified + + +def _get_radio_button_properties(device): + """Return the values and schema to set KPL buttons as radio buttons.""" + rb_groups = _calc_radio_button_groups(device) + props = [] + schema = {} + index = 0 + remaining_buttons = [] + + buttons_in_groups = list(chain.from_iterable(rb_groups)) + + # Identify buttons not belonging to any group + for button in device.groups: + if button not in buttons_in_groups: + remaining_buttons.append(button) + + for rb_group in rb_groups: + name = f"{RADIO_BUTTON_GROUP_PROP}{index}" + button_1 = rb_group[0] + button_str = f"_{button_1}" if button_1 != 1 else "" + on_mask = device.properties[f"{ON_MASK}{button_str}"] + off_mask = device.properties[f"{OFF_MASK}{button_str}"] + modified = on_mask.is_dirty or off_mask.is_dirty + + props.append( + { + "name": name, + "modified": modified, + "value": rb_group, + } + ) + + options = { + button: device.groups[button].name + for button in chain.from_iterable([rb_group, remaining_buttons]) + } + rb_schema = vol.Schema({vol.Optional(name): cv.multi_select(options)}) + + rb_schema_dict = voluptuous_serialize.convert( + rb_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = rb_schema_dict[0] + + index += 1 + + if len(remaining_buttons) > 1: + name = f"{RADIO_BUTTON_GROUP_PROP}{index}" + + props.append( + { + "name": name, + "modified": False, + "value": [], + } + ) + + options = {button: device.groups[button].name for button in remaining_buttons} + rb_schema = vol.Schema({vol.Optional(name): cv.multi_select(options)}) + + rb_schema_dict = voluptuous_serialize.convert( + rb_schema, custom_serializer=cv.custom_serializer + ) + schema[name] = rb_schema_dict[0] + + return props, schema + + +def _calc_radio_button_groups(device): + """Return existing radio button groups.""" + rb_groups = [] + for button in device.groups: + if button not in list(chain.from_iterable(rb_groups)): + button_str = "" if button == 1 else f"_{button}" + on_mask, _ = _get_usable_value(device.properties[f"{ON_MASK}{button_str}"]) + if on_mask != 0: + rb_group = [button] + for bit in list(range(0, button - 1)) + list(range(button, 8)): + if on_mask & 1 << bit: + rb_group.append(bit + 1) + if len(rb_group) > 1: + rb_groups.append(rb_group) + return rb_groups + + +def _get_ramp_rate_property(prop): + """Return the value and schema of a ramp rate property.""" + rr_prop, _ = _get_property(prop) + rr_prop["value"] = ramp_rate_to_seconds(rr_prop["value"]) + return rr_prop, _ramp_rate_schema(prop.name) + + +def _get_usable_value(prop): + """Return the current or the modified value of a property.""" + value = prop.value if prop.new_value is None else prop.new_value + return value, prop.is_dirty + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/get", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_get_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + properties, schema = get_properties(device) + + connection.send_result(msg[ID], {"properties": properties, "schema": schema}) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/change", + vol.Required(DEVICE_ADDRESS): str, + vol.Required(PROPERTY_NAME): str, + vol.Required(PROPERTY_VALUE): vol.Any(list, int, float, bool, str), + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_change_properties_record( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + set_property(device, msg[PROPERTY_NAME], msg[PROPERTY_VALUE]) + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/write", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_write_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + result1 = await device.async_write_op_flags() + result2 = await device.async_write_ext_properties() + await devices.async_save(workdir=hass.config.config_dir) + if result1 != ResponseStatus.SUCCESS or result2 != ResponseStatus.SUCCESS: + connection.send_message( + websocket_api.error_message( + msg[ID], "write_failed", "properties not written to device" + ) + ) + return + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/load", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_load_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + result1 = await device.async_read_op_flags() + result2 = await device.async_read_ext_properties() + await devices.async_save(workdir=hass.config.config_dir) + if result1 != ResponseStatus.SUCCESS or result2 != ResponseStatus.SUCCESS: + connection.send_message( + websocket_api.error_message( + msg[ID], "load_failed", "properties not loaded from device" + ) + ) + return + connection.send_result(msg[ID]) + + +@websocket_api.websocket_command( + { + vol.Required(TYPE): "insteon/properties/reset", + vol.Required(DEVICE_ADDRESS): str, + } +) +@websocket_api.require_admin +@websocket_api.async_response +async def websocket_reset_properties( + hass: HomeAssistant, + connection: websocket_api.connection.ActiveConnection, + msg: dict, +) -> None: + """Add the default All-Link Database records for an Insteon device.""" + device = devices[msg[DEVICE_ADDRESS]] + if not device: + notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) + return + + for prop in device.operating_flags: + device.operating_flags[prop].new_value = None + for prop in device.properties: + device.properties[prop].new_value = None + connection.send_result(msg[ID]) diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index 7e034311a82..dbfa3e8b2d9 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -224,7 +224,7 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): """Register INSTEON update events.""" await super().async_added_to_hass() await self._insteon_device.async_read_op_flags() - for group in [ + for group in ( COOLING, HEATING, DEHUMIDIFYING, @@ -236,5 +236,5 @@ class InsteonClimateEntity(InsteonEntity, ClimateEntity): HUMIDITY, HUMIDITY_HIGH, HUMIDITY_LOW, - ]: + ): self._insteon_device.groups[group].subscribe(self.async_entity_update) diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index a40a0b0d4b0..dca53d20369 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -1,4 +1,6 @@ """Constants used by insteon component.""" +import re + from pyinsteon.groups import ( CO_SENSOR, COVER, @@ -158,3 +160,15 @@ STATE_NAME_LABEL_MAP = { COVER: "Cover", RELAY: "Relay", } + +TYPE = "type" +ID = "id" +DEVICE_ID = "device_id" +DEVICE_ADDRESS = "device_address" +ALDB_RECORD = "record" +PROPERTY_NAME = "name" +PROPERTY_VALUE = "value" +HA_DEVICE_NOT_FOUND = "ha_device_not_found" +INSTEON_DEVICE_NOT_FOUND = "insteon_device_not_found" + +INSTEON_ADDR_REGEX = re.compile(r"([A-Fa-f0-9]{2}\.?[A-Fa-f0-9]{2}\.?[A-Fa-f0-9]{2})$") diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 00ada3e9a58..b76661d7dde 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -1,4 +1,6 @@ """Support for INSTEON fans via PowerLinc Modem.""" +from __future__ import annotations + import math from homeassistant.components.fan import ( @@ -39,7 +41,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """An INSTEON fan entity.""" @property - def percentage(self) -> int: + def percentage(self) -> int | None: """Return the current speed percentage.""" if self._insteon_device_group.value is None: return None diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index 48223981103..9b32bc40043 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -110,4 +110,4 @@ def get_device_platforms(device): def get_platform_groups(device, domain) -> dict: """Return the platforms that a device belongs in.""" - return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) + return DEVICE_PLATFORM.get(type(device), {}).get(domain, {}) # type: ignore diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 4643a8c662a..f5f9d57d8a8 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -10,4 +10,4 @@ ], "config_flow": true, "iot_class": "local_push" -} \ No newline at end of file +} diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index c43df24b4cb..626dc7dde4b 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -40,6 +40,7 @@ from .const import ( CONF_X10_ALL_UNITS_OFF, DOMAIN, HOUSECODES, + INSTEON_ADDR_REGEX, PORT_HUB_V1, PORT_HUB_V2, SRV_ALL_LINK_GROUP, @@ -64,6 +65,13 @@ def set_default_port(schema: dict) -> dict: return schema +def insteon_address(value: str) -> str: + """Validate an Insteon address.""" + if not INSTEON_ADDR_REGEX.match(value): + raise vol.Invalid("Invalid Insteon Address") + return str(value).replace(".", "").lower() + + CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( vol.Schema( { @@ -161,7 +169,7 @@ TRIGGER_SCENE_SCHEMA = vol.Schema( ADD_DEFAULT_LINKS_SCHEMA = vol.Schema({vol.Required(CONF_ENTITY_ID): cv.entity_id}) -def normalize_byte_entry_to_int(entry: [int, bytes, str]): +def normalize_byte_entry_to_int(entry: int | bytes | str): """Format a hex entry value.""" if isinstance(entry, int): if entry in range(0, 256): diff --git a/homeassistant/components/insteon/translations/de.json b/homeassistant/components/insteon/translations/de.json index 84b53c4aa15..0866f53481f 100644 --- a/homeassistant/components/insteon/translations/de.json +++ b/homeassistant/components/insteon/translations/de.json @@ -6,7 +6,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "select_single": "W\u00e4hlen Sie eine Option aus." + "select_single": "W\u00e4hle eine Option aus." }, "step": { "hubv1": { @@ -14,7 +14,7 @@ "host": "IP-Adresse", "port": "Port" }, - "description": "Konfigurieren Sie den Insteon Hub Version 1 (vor 2014).", + "description": "Konfiguriere den Insteon Hub Version 1 (vor 2014).", "title": "Insteon Hub Version 1" }, "hubv2": { @@ -24,21 +24,21 @@ "port": "Port", "username": "Benutzername" }, - "description": "Konfigurieren Sie den Insteon Hub Version 2.", + "description": "Konfiguriere den Insteon Hub Version 2.", "title": "Insteon Hub Version 2" }, "plm": { "data": { "device": "USB-Ger\u00e4te-Pfad" }, - "description": "Konfigurieren Sie das Insteon PowerLink Modem (PLM).", + "description": "Konfiguriere das Insteon PowerLink Modem (PLM).", "title": "Insteon PLM" }, "user": { "data": { "modem_type": "Modemtyp." }, - "description": "W\u00e4hlen Sie den Insteon-Modemtyp aus.", + "description": "W\u00e4hle den Insteon-Modemtyp aus.", "title": "Insteon" } } @@ -46,7 +46,7 @@ "options": { "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "input_error": "Ung\u00fcltige Eingaben, bitte \u00fcberpr\u00fcfen Sie Ihre Werte.", + "input_error": "Ung\u00fcltige Eingaben, bitte \u00fcberpr\u00fcfe deine Werte.", "select_single": "W\u00e4hle eine Option aus." }, "step": { @@ -56,7 +56,7 @@ "cat": "Ger\u00e4tekategorie (z. B. 0x10)", "subcat": "Ger\u00e4teunterkategorie (z. B. 0x0a)" }, - "description": "F\u00fcgen Sie eine Ger\u00e4te\u00fcberschreibung hinzu.", + "description": "F\u00fcge eine Ger\u00e4te\u00fcberschreibung hinzu.", "title": "Insteon" }, "add_x10": { @@ -66,7 +66,7 @@ "steps": "Dimmerstufen (nur f\u00fcr Lichtger\u00e4te, Voreinstellung 22)", "unitcode": "Unitcode (1 - 16)" }, - "description": "\u00c4ndern Sie das Insteon Hub-Passwort.", + "description": "\u00c4ndere das Insteon Hub-Passwort.", "title": "Insteon" }, "change_hub_config": { @@ -76,30 +76,30 @@ "port": "Port", "username": "Benutzername" }, - "description": "\u00c4ndern Sie die Verbindungsinformationen des Insteon-Hubs. Sie m\u00fcssen Home Assistant neu starten, nachdem Sie diese \u00c4nderung vorgenommen haben. Dies \u00e4ndert nicht die Konfiguration des Hubs selbst. Um die Konfiguration im Hub zu \u00e4ndern, verwenden Sie die Hub-App.", + "description": "\u00c4ndere die Verbindungsinformationen des Insteon-Hubs. Du musst Home Assistant neu starten, nachdem du diese \u00c4nderung vorgenommen hast. Dies \u00e4ndert nicht die Konfiguration des Hubs selbst. Um die Konfiguration im Hub zu \u00e4ndern, verwende die Hub-App.", "title": "Insteon" }, "init": { "data": { - "add_override": "F\u00fcgen Sie eine Ger\u00e4te\u00fcberschreibung hinzu.", - "add_x10": "F\u00fcgen Sie ein X10-Ger\u00e4t hinzu.", - "change_hub_config": "\u00c4ndern Sie die Konfiguration des Hubs.", - "remove_override": "Entfernen Sie eine Ger\u00e4te\u00fcbersteuerung.", - "remove_x10": "Entfernen Sie ein X10-Ger\u00e4t." + "add_override": "F\u00fcge eine Ger\u00e4te\u00fcberschreibung hinzu.", + "add_x10": "F\u00fcge ein X10-Ger\u00e4t hinzu.", + "change_hub_config": "\u00c4ndere die Konfiguration des Hubs.", + "remove_override": "Entferne eine Ger\u00e4te\u00fcbersteuerung.", + "remove_x10": "Entferne ein X10-Ger\u00e4t." }, - "description": "W\u00e4hlen Sie eine Option zum Konfigurieren aus.", + "description": "W\u00e4hle eine Option zum Konfigurieren aus.", "title": "Insteon" }, "remove_override": { "data": { - "address": "W\u00e4hlen Sie eine Ger\u00e4teadresse zum Entfernen" + "address": "W\u00e4hle eine Ger\u00e4teadresse zum Entfernen" }, "description": "Entfernen einer Ger\u00e4te\u00fcbersteuerung", "title": "Insteon" }, "remove_x10": { "data": { - "address": "W\u00e4hlen Sie eine Ger\u00e4teadresse zum Entfernen" + "address": "W\u00e4hle eine Ger\u00e4teadresse zum Entfernen" }, "description": "Ein X10-Ger\u00e4t entfernen", "title": "Insteon" diff --git a/homeassistant/components/insteon/translations/hu.json b/homeassistant/components/insteon/translations/hu.json index 06033fb1321..462fae3e1cb 100644 --- a/homeassistant/components/insteon/translations/hu.json +++ b/homeassistant/components/insteon/translations/hu.json @@ -13,7 +13,9 @@ "data": { "host": "IP c\u00edm", "port": "Port" - } + }, + "description": "Konfigur\u00e1lja az Insteon Hub 1. verzi\u00f3j\u00e1t (2014 el\u0151tti).", + "title": "Insteon Hub 1. verzi\u00f3" }, "hubv2": { "data": { @@ -22,12 +24,14 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "Konfigur\u00e1lja az Insteon Hub 2. verzi\u00f3j\u00e1t.", "title": "Insteon Hub 2. verzi\u00f3" }, "plm": { "data": { "device": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" - } + }, + "title": "Insteon PLM" }, "user": { "data": { @@ -47,6 +51,9 @@ "title": "Insteon" }, "add_x10": { + "data": { + "unitcode": "Egys\u00e9gk\u00f3d (1 - 16)" + }, "title": "Insteon" }, "change_hub_config": { @@ -59,12 +66,19 @@ "title": "Insteon" }, "init": { + "data": { + "add_x10": "Adjon hozz\u00e1 egy X10 eszk\u00f6zt." + }, "title": "Insteon" }, "remove_override": { "title": "Insteon" }, "remove_x10": { + "data": { + "address": "V\u00e1lassza ki az elt\u00e1vol\u00edtani k\u00edv\u00e1nt eszk\u00f6z c\u00edm\u00e9t" + }, + "description": "T\u00e1vol\u00edtson el egy X10 eszk\u00f6zt", "title": "Insteon" } } diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 0ab4ac0d2c4..dea8970f4f7 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -4,8 +4,16 @@ import logging import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_METHOD, CONF_NAME, @@ -20,6 +28,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util # mypy: allow-untyped-defs, no-check-untyped-defs @@ -115,16 +124,26 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._attr_state_class = STATE_CLASS_MEASUREMENT async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() state = await self.async_get_last_state() + self._attr_last_reset = dt_util.utcnow() if state: try: self._state = Decimal(state.state) - except ValueError as err: + except (DecimalException, ValueError) as err: _LOGGER.warning("Could not restore last state: %s", err) + else: + last_reset = dt_util.parse_datetime( + state.attributes.get(ATTR_LAST_RESET, "") + ) + self._attr_last_reset = ( + last_reset if last_reset else dt_util.utc_from_timestamp(0) + ) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) @callback def calc_integration(event): @@ -143,7 +162,11 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_of_measurement = self._unit_template.format( "" if unit is None else unit ) - + if ( + self.device_class is None + and new_state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + ): + self._attr_device_class = DEVICE_CLASS_ENERGY try: # integration as the Riemann integral of previous measures. area = 0 diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 853fb0d479a..a096a43ac85 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -38,7 +38,7 @@ def log_rate_limits(hass, target, resp, level=20): rate_limits["successful"], rate_limits["maximum"], rate_limits["errors"], - str(resetsAtTime).split(".")[0], + str(resetsAtTime).split(".", maxsplit=1)[0], ) diff --git a/homeassistant/components/ios/translations/de.json b/homeassistant/components/ios/translations/de.json index bc427bd2992..d0927f55f00 100644 --- a/homeassistant/components/ios/translations/de.json +++ b/homeassistant/components/ios/translations/de.json @@ -5,7 +5,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chtest du die Home Assistant iOS-Komponente einrichten?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 842bfc91d9a..b760fccb598 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -26,7 +26,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import CONF_BASE_PATH, CONF_SERIAL, CONF_UUID, DOMAIN @@ -62,7 +61,9 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): """Set up the instance.""" self.discovery_info = {} - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if user_input is None: return self._show_setup_form() @@ -98,7 +99,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) - async def async_step_zeroconf(self, discovery_info: ConfigType) -> FlowResult: + async def async_step_zeroconf(self, discovery_info: dict[str, Any]) -> FlowResult: """Handle zeroconf discovery.""" port = discovery_info[CONF_PORT] zctype = discovery_info["type"] @@ -165,7 +166,7 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_zeroconf_confirm() async def async_step_zeroconf_confirm( - self, user_input: ConfigType = None + self, user_input: dict[str, Any] = None ) -> FlowResult: """Handle a confirmation flow initiated by zeroconf.""" if user_input is None: diff --git a/homeassistant/components/ipp/translations/de.json b/homeassistant/components/ipp/translations/de.json index 80497c0c874..9886bf9c0ef 100644 --- a/homeassistant/components/ipp/translations/de.json +++ b/homeassistant/components/ipp/translations/de.json @@ -11,7 +11,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "connection_upgrade": "Verbindung zum Drucker fehlgeschlagen. Bitte versuchen Sie es erneut mit aktivierter SSL / TLS-Option." + "connection_upgrade": "Verbindung zum Drucker fehlgeschlagen. Bitte versuche es erneut mit aktivierter SSL / TLS-Option." }, "flow_title": "{name}", "step": { @@ -23,8 +23,8 @@ "ssl": "Verwendet ein SSL-Zertifikat", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, - "description": "Richten Sie Ihren Drucker \u00fcber das Internet Printing Protocol (IPP) f\u00fcr die Integration in Home Assistant ein.", - "title": "Verbinden Sie Ihren Drucker" + "description": "Richte deinen Drucker \u00fcber das Internet Printing Protocol (IPP) f\u00fcr die Integration in Home Assistant ein.", + "title": "Verbinde deinen Drucker" }, "zeroconf_confirm": { "description": "M\u00f6chtest du {name} einrichten?", diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 5d13a2373a6..fa783cc9031 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -59,7 +59,7 @@ async def async_setup_entry(hass, entry): raise UpdateFailed from err init_data_update_tasks = [] - for sensor_type, api_coro in [ + for sensor_type, api_coro in ( (TYPE_ALLERGY_FORECAST, client.allergens.extended), (TYPE_ALLERGY_INDEX, client.allergens.current), (TYPE_ALLERGY_OUTLOOK, client.allergens.outlook), @@ -67,7 +67,7 @@ async def async_setup_entry(hass, entry): (TYPE_ASTHMA_INDEX, client.asthma.current), (TYPE_DISEASE_FORECAST, client.disease.extended), (TYPE_DISEASE_INDEX, client.disease.current), - ]: + ): coordinator = coordinators[sensor_type] = DataUpdateCoordinator( hass, LOGGER, @@ -104,43 +104,15 @@ class IQVIAEntity(CoordinatorEntity, SensorEntity): def __init__(self, coordinator, entry, sensor_type, name, icon): """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_icon = icon + self._attr_name = name + self._attr_unique_id = f"{entry.data[CONF_ZIP_CODE]}_{sensor_type}" + self._attr_unit_of_measurement = "index" self._entry = entry - self._icon = icon - self._name = name - self._state = None self._type = sensor_type - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def name(self): - """Return the name.""" - return self._name - - @property - def state(self): - """Return the state.""" - return self._state - - @property - def unique_id(self): - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._entry.data[CONF_ZIP_CODE]}_{self._type}" - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return "index" - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 75249ded6a1..da50819c9a0 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.20.3", "pyiqvia==1.0.0"], + "requirements": ["numpy==1.21.1", "pyiqvia==1.0.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index b0420a52ee9..0ff236a8f79 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -120,7 +120,7 @@ class ForecastSensor(IQVIAEntity): if i["minimum"] <= average <= i["maximum"] ] - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_CITY: data["City"].title(), ATTR_RATING: rating, @@ -134,10 +134,14 @@ class ForecastSensor(IQVIAEntity): outlook_coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR][ self._entry.entry_id ][TYPE_ALLERGY_OUTLOOK] - self._attrs[ATTR_OUTLOOK] = outlook_coordinator.data.get("Outlook") - self._attrs[ATTR_SEASON] = outlook_coordinator.data.get("Season") + self._attr_extra_state_attributes[ + ATTR_OUTLOOK + ] = outlook_coordinator.data.get("Outlook") + self._attr_extra_state_attributes[ + ATTR_SEASON + ] = outlook_coordinator.data.get("Season") - self._state = average + self._attr_state = average class IndexSensor(IQVIAEntity): @@ -172,7 +176,7 @@ class IndexSensor(IQVIAEntity): if i["minimum"] <= period["Index"] <= i["maximum"] ] - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_CITY: data["City"].title(), ATTR_RATING: rating, @@ -184,7 +188,7 @@ class IndexSensor(IQVIAEntity): if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 - self._attrs.update( + self._attr_extra_state_attributes.update( { f"{ATTR_ALLERGEN_GENUS}_{index}": attrs["Genus"], f"{ATTR_ALLERGEN_NAME}_{index}": attrs["Name"], @@ -194,7 +198,7 @@ class IndexSensor(IQVIAEntity): elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): for idx, attrs in enumerate(period["Triggers"]): index = idx + 1 - self._attrs.update( + self._attr_extra_state_attributes.update( { f"{ATTR_ALLERGEN_NAME}_{index}": attrs["Name"], f"{ATTR_ALLERGEN_AMOUNT}_{index}": attrs["PPM"], @@ -202,6 +206,8 @@ class IndexSensor(IQVIAEntity): ) elif self._type == TYPE_DISEASE_TODAY: for attrs in period["Triggers"]: - self._attrs[f"{attrs['Name'].lower()}_index"] = attrs["Index"] + self._attr_extra_state_attributes[ + f"{attrs['Name'].lower()}_index" + ] = attrs["Index"] - self._state = period["Index"] + self._attr_state = period["Index"] diff --git a/homeassistant/components/iqvia/translations/de.json b/homeassistant/components/iqvia/translations/de.json index 1318a9c90cc..5d307eea829 100644 --- a/homeassistant/components/iqvia/translations/de.json +++ b/homeassistant/components/iqvia/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dienst ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "invalid_zip_code": "Postleitzahl ist ung\u00fcltig" diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 51c34aeb0a7..e3d11efd739 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -244,11 +244,11 @@ def _async_import_options_from_data_if_missing( ): options = dict(entry.options) modified = False - for importable_option in [ + for importable_option in ( CONF_IGNORE_STRING, CONF_SENSOR_STRING, CONF_RESTORE_LIGHT_STATE, - ]: + ): if importable_option not in entry.options and importable_option in entry.data: options[importable_option] = entry.data[importable_option] modified = True diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index c9ca29e8f63..58e5238cbee 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,6 +1,6 @@ """Config flow for Universal Devices ISY994 integration.""" import logging -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse from aiohttp import CookieJar import async_timeout @@ -9,7 +9,7 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant import config_entries, core, exceptions +from homeassistant import config_entries, core, data_entry_flow, exceptions from homeassistant.components import ssdp from homeassistant.components.dhcp import HOSTNAME, IP_ADDRESS, MAC_ADDRESS from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME @@ -28,7 +28,11 @@ from .const import ( DEFAULT_TLS_VERSION, DEFAULT_VAR_SENSOR_STRING, DOMAIN, + HTTP_PORT, + HTTPS_PORT, ISY_URL_POSTFIX, + SCHEME_HTTP, + SCHEME_HTTPS, UDN_UUID_PREFIX, ) @@ -58,15 +62,15 @@ async def validate_input(hass: core.HomeAssistant, data): host = urlparse(data[CONF_HOST]) tls_version = data.get(CONF_TLS_VER) - if host.scheme == "http": + if host.scheme == SCHEME_HTTP: https = False - port = host.port or 80 + port = host.port or HTTP_PORT session = aiohttp_client.async_create_clientsession( hass, verify_ssl=None, cookie_jar=CookieJar(unsafe=True) ) - elif host.scheme == "https": + elif host.scheme == SCHEME_HTTPS: https = True - port = host.port or 443 + port = host.port or HTTPS_PORT session = aiohttp_client.async_get_clientsession(hass) else: _LOGGER.error("The isy994 host value in configuration is invalid") @@ -150,6 +154,39 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle import.""" return await self.async_step_user(user_input) + async def _async_set_unique_id_or_update(self, isy_mac, ip_address, port) -> None: + """Abort and update the ip address on change.""" + existing_entry = await self.async_set_unique_id(isy_mac) + if not existing_entry: + return + parsed_url = urlparse(existing_entry.data[CONF_HOST]) + if parsed_url.hostname != ip_address: + new_netloc = ip_address + if port: + new_netloc = f"{ip_address}:{port}" + elif parsed_url.port: + new_netloc = f"{ip_address}:{parsed_url.port}" + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **existing_entry.data, + CONF_HOST: urlunparse( + ( + parsed_url.scheme, + new_netloc, + parsed_url.path, + parsed_url.query, + parsed_url.fragment, + None, + ) + ), + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) + raise data_entry_flow.AbortFlow("already_configured") + async def async_step_dhcp(self, discovery_info): """Handle a discovered isy994 via dhcp.""" friendly_name = discovery_info[HOSTNAME] @@ -158,8 +195,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): isy_mac = ( f"{mac[0:2]}:{mac[2:4]}:{mac[4:6]}:{mac[6:8]}:{mac[8:10]}:{mac[10:12]}" ) - await self.async_set_unique_id(isy_mac) - self._abort_if_unique_id_configured() + await self._async_set_unique_id_or_update( + isy_mac, discovery_info[IP_ADDRESS], None + ) self.discovered_conf = { CONF_NAME: friendly_name, @@ -173,14 +211,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a discovered isy994.""" friendly_name = discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME] url = discovery_info[ssdp.ATTR_SSDP_LOCATION] + parsed_url = urlparse(url) mac = discovery_info[ssdp.ATTR_UPNP_UDN] if mac.startswith(UDN_UUID_PREFIX): mac = mac[len(UDN_UUID_PREFIX) :] if url.endswith(ISY_URL_POSTFIX): url = url[: -len(ISY_URL_POSTFIX)] - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() + port = HTTP_PORT + if parsed_url.port: + port = parsed_url.port + elif parsed_url.scheme == SCHEME_HTTPS: + port = HTTPS_PORT + + await self._async_set_unique_id_or_update(mac, parsed_url.hostname, port) self.discovered_conf = { CONF_NAME: friendly_name, diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index ed40c7eb289..b7b2f283a84 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -48,6 +48,9 @@ from homeassistant.const import ( CURRENCY_CENT, CURRENCY_DOLLAR, DEGREE, + ELECTRIC_CURRENT_MILLIAMPERE, + ELECTRIC_POTENTIAL_MILLIVOLT, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, @@ -71,6 +74,8 @@ from homeassistant.const import ( PRESSURE_MBAR, SERVICE_LOCK, SERVICE_UNLOCK, + SOUND_PRESSURE_DB, + SOUND_PRESSURE_WEIGHTED_DBA, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, @@ -98,7 +103,6 @@ from homeassistant.const import ( TIME_SECONDS, TIME_YEARS, UV_INDEX, - VOLT, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, VOLUME_FLOW_RATE_CUBIC_FEET_PER_MINUTE, @@ -341,8 +345,8 @@ UOM_FRIENDLY_NAME = { "8": VOLUME_CUBIC_METERS, "9": TIME_DAYS, "10": TIME_DAYS, - "12": "dB", - "13": "dB A", + "12": SOUND_PRESSURE_DB, + "13": SOUND_PRESSURE_WEIGHTED_DBA, "14": DEGREE, "16": "macroseismic", "17": TEMP_FAHRENHEIT, @@ -369,9 +373,9 @@ UOM_FRIENDLY_NAME = { "38": LENGTH_METERS, "39": VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, "40": SPEED_METERS_PER_SECOND, - "41": "mA", + "41": ELECTRIC_CURRENT_MILLIAMPERE, "42": TIME_MILLISECONDS, - "43": "mV", + "43": ELECTRIC_POTENTIAL_MILLIVOLT, "44": TIME_MINUTES, "45": TIME_MINUTES, "46": PRECIPITATION_MILLIMETERS_PER_HOUR, @@ -395,7 +399,7 @@ UOM_FRIENDLY_NAME = { "65": "SML", "69": VOLUME_GALLONS, "71": UV_INDEX, - "72": VOLT, + "72": ELECTRIC_POTENTIAL_VOLT, "73": POWER_WATT, "74": IRRADIATION_WATTS_PER_SQUARE_METER, "75": "weekday", @@ -668,3 +672,9 @@ BINARY_SENSOR_DEVICE_TYPES_ZWAVE = { DEVICE_CLASS_MOTION: ["155"], DEVICE_CLASS_VIBRATION: ["173"], } + + +SCHEME_HTTP = "http" +HTTP_PORT = 80 +SCHEME_HTTPS = "https" +HTTPS_PORT = 443 diff --git a/homeassistant/components/isy994/translations/ar.json b/homeassistant/components/isy994/translations/ar.json new file mode 100644 index 00000000000..e3805ee46f8 --- /dev/null +++ b/homeassistant/components/isy994/translations/ar.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "init": { + "data": { + "restore_light_state": "\u0627\u0633\u062a\u0639\u0627\u062f\u0629 \u0633\u0637\u0648\u0639 \u0627\u0644\u0636\u0648\u0621" + }, + "description": "\u062a\u0639\u064a\u064a\u0646 \u0627\u0644\u062e\u064a\u0627\u0631\u0627\u062a \u0644\u0644\u062a\u0643\u0627\u0645\u0644 ISY: \n \u2022 \u0633\u0644\u0633\u0644\u0629 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0639\u0642\u062f\u0629: \u0623\u064a \u062c\u0647\u0627\u0632 \u0623\u0648 \u0645\u062c\u0644\u062f \u064a\u062d\u062a\u0648\u064a \u0639\u0644\u0649 \"\u0633\u0644\u0633\u0644\u0629 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0639\u0642\u062f\u0629\" \u0641\u064a \u0627\u0644\u0627\u0633\u0645 \u0633\u064a\u062a\u0645 \u0627\u0644\u062a\u0639\u0627\u0645\u0644 \u0645\u0639\u0647\u0627 \u0639\u0644\u0649 \u0623\u0646\u0647\u0627 \u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0623\u0648 \u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u062b\u0646\u0627\u0626\u064a. \n \u2022 \u062a\u062c\u0627\u0647\u0644 \u0627\u0644\u0633\u0644\u0633\u0644\u0629: \u0633\u064a\u062a\u0645 \u062a\u062c\u0627\u0647\u0644 \u0623\u064a \u062c\u0647\u0627\u0632 \u0645\u0639 '\u062a\u062c\u0627\u0647\u0644 \u0633\u0644\u0633\u0644\u0629' \u0641\u064a \u0627\u0644\u0627\u0633\u0645. \n \u2022 \u0633\u0644\u0633\u0644\u0629 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0645\u062a\u063a\u064a\u0631: \u0623\u064a \u0645\u062a\u063a\u064a\u0631 \u064a\u062d\u062a\u0648\u064a \u0639\u0644\u0649 \"\u0633\u0644\u0633\u0644\u0629 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0645\u062a\u063a\u064a\u0631\" \u0633\u064a\u062a\u0645 \u0625\u0636\u0627\u0641\u062a\u0647\u0627 \u0643\u0645\u0633\u062a\u0634\u0639\u0631. \n \u2022 \u0627\u0633\u062a\u0639\u0627\u062f\u0629 \u0633\u0637\u0648\u0639 \u0627\u0644\u0636\u0648\u0621: \u0625\u0630\u0627 \u062a\u0645 \u062a\u0645\u0643\u064a\u0646\u0647\u060c \u0633\u064a\u062a\u0645 \u0627\u0633\u062a\u0639\u0627\u062f\u0629 \u0627\u0644\u0633\u0637\u0648\u0639 \u0627\u0644\u0633\u0627\u0628\u0642 \u0639\u0646\u062f \u062a\u0634\u063a\u064a\u0644 \u0636\u0648\u0621 \u0628\u062f\u0644\u0627 \u0645\u0646 \u0627\u0644\u0645\u062f\u0645\u062c \u0641\u064a \u0627\u0644\u062c\u0647\u0627\u0632 \u0639\u0644\u0649 \u0627\u0644\u0645\u0633\u062a\u0648\u0649." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/fr.json b/homeassistant/components/isy994/translations/fr.json index 01f2145cd12..0bd04cd14b1 100644 --- a/homeassistant/components/isy994/translations/fr.json +++ b/homeassistant/components/isy994/translations/fr.json @@ -36,5 +36,13 @@ "title": "Options ISY994" } } + }, + "system_health": { + "info": { + "device_connected": "ISY connect\u00e9", + "host_reachable": "H\u00f4te joignable", + "last_heartbeat": "Heure du dernier pulsation", + "websocket_status": "\u00c9tat du socket d'\u00e9v\u00e9nement" + } } } \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/hu.json b/homeassistant/components/isy994/translations/hu.json index ca8646ec584..065be706d0f 100644 --- a/homeassistant/components/isy994/translations/hu.json +++ b/homeassistant/components/isy994/translations/hu.json @@ -25,5 +25,13 @@ "title": "ISY994 Be\u00e1ll\u00edt\u00e1sok" } } + }, + "system_health": { + "info": { + "device_connected": "ISY csatlakozik", + "host_reachable": "El\u00e9rhet\u0151 gazdag\u00e9p", + "last_heartbeat": "Utols\u00f3 sz\u00edvver\u00e9s ideje", + "websocket_status": "Esem\u00e9nySocket \u00e1llapota" + } } } \ No newline at end of file diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py index 29ec7eb4558..2b531773d55 100644 --- a/homeassistant/components/itunes/media_player.py +++ b/homeassistant/components/itunes/media_player.py @@ -384,7 +384,7 @@ class ItunesDevice(MediaPlayerEntity): def media_next_track(self): """Send media_next command to media player.""" - response = self.client.next() # pylint: disable=not-callable + response = self.client.next() self.update_state(response) def media_previous_track(self): diff --git a/homeassistant/components/izone/translations/de.json b/homeassistant/components/izone/translations/de.json index f6e03c3af27..6b9441d1683 100644 --- a/homeassistant/components/izone/translations/de.json +++ b/homeassistant/components/izone/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine iZone-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index bda2bd5a117..954b22debd0 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -29,41 +29,23 @@ class JewishCalendarBinarySensor(BinarySensorEntity): def __init__(self, data, sensor, sensor_info): """Initialize the binary sensor.""" - self._location = data["location"] self._type = sensor - self._name = f"{data['name']} {sensor_info[0]}" - self._icon = sensor_info[1] + self._prefix = data["prefix"] + self._attr_name = f"{data['name']} {sensor_info[0]}" + self._attr_unique_id = f"{self._prefix}_{self._type}" + self._attr_icon = sensor_info[1] + self._attr_should_poll = False + self._location = data["location"] self._hebrew = data["language"] == "hebrew" self._candle_lighting_offset = data["candle_lighting_offset"] self._havdalah_offset = data["havdalah_offset"] - self._prefix = data["prefix"] self._update_unsub = None - @property - def icon(self): - """Return the icon of the entity.""" - return self._icon - - @property - def unique_id(self) -> str: - """Generate a unique id.""" - return f"{self._prefix}_{self._type}" - - @property - def name(self): - """Return the name of the entity.""" - return self._name - @property def is_on(self): """Return true if sensor is on.""" return self._get_zmanim().issur_melacha_in_effect - @property - def should_poll(self): - """No polling needed.""" - return False - def _get_zmanim(self): """Return the Zmanim object for now().""" return hdate.Zmanim( diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 5690cd35a03..17a61c932a3 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,4 +1,5 @@ """Platform to retrieve Jewish calendar information for Home Assistant.""" +from datetime import datetime import logging import hdate @@ -35,36 +36,24 @@ class JewishCalendarSensor(SensorEntity): def __init__(self, data, sensor, sensor_info): """Initialize the Jewish calendar sensor.""" - self._location = data["location"] self._type = sensor - self._name = f"{data['name']} {sensor_info[0]}" - self._icon = sensor_info[1] + self._prefix = data["prefix"] + self._attr_name = f"{data['name']} {sensor_info[0]}" + self._attr_unique_id = f"{self._prefix}_{self._type}" + self._attr_icon = sensor_info[1] + 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._state = None - self._prefix = data["prefix"] self._holiday_attrs = {} - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self) -> str: - """Generate a unique id.""" - return f"{self._prefix}_{self._type}" - - @property - def icon(self): - """Icon to display in the front end.""" - return self._icon - @property def state(self): """Return the state of the sensor.""" + if isinstance(self._state, datetime): + return self._state.isoformat() return self._state async def async_update(self): @@ -142,15 +131,14 @@ class JewishCalendarSensor(SensorEntity): class JewishCalendarTimeSensor(JewishCalendarSensor): """Implement attrbutes for sensors returning times.""" + _attr_device_class = DEVICE_CLASS_TIMESTAMP + @property def state(self): """Return the state of the sensor.""" - return dt_util.as_utc(self._state) if self._state is not None else None - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_TIMESTAMP + if self._state is None: + return None + return dt_util.as_utc(self._state).isoformat() @property def extra_state_attributes(self): diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 7564c6e4344..51792daf38c 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,25 +1,40 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( - ELECTRICAL_CURRENT_AMPERE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, - VOLT, ) from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR from .entity import JuiceNetDevice SENSOR_TYPES = { - "status": ["Charging Status", None, None], - "temperature": ["Temperature", TEMP_CELSIUS, STATE_CLASS_MEASUREMENT], - "voltage": ["Voltage", VOLT, None], - "amps": ["Amps", ELECTRICAL_CURRENT_AMPERE, STATE_CLASS_MEASUREMENT], - "watts": ["Watts", POWER_WATT, STATE_CLASS_MEASUREMENT], - "charge_time": ["Charge time", TIME_SECONDS, None], - "energy_added": ["Energy added", ENERGY_WATT_HOUR, None], + "status": ["Charging Status", None, None, None], + "temperature": [ + "Temperature", + TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + ], + "voltage": ["Voltage", ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE, None], + "amps": [ + "Amps", + ELECTRIC_CURRENT_AMPERE, + DEVICE_CLASS_CURRENT, + STATE_CLASS_MEASUREMENT, + ], + "watts": ["Watts", POWER_WATT, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], + "charge_time": ["Charge time", TIME_SECONDS, None, None], + "energy_added": ["Energy added", ENERGY_WATT_HOUR, DEVICE_CLASS_ENERGY, None], } @@ -44,7 +59,8 @@ class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): super().__init__(device, sensor_type, coordinator) self._name = SENSOR_TYPES[sensor_type][0] self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._attr_state_class = SENSOR_TYPES[sensor_type][2] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] + self._attr_state_class = SENSOR_TYPES[sensor_type][3] @property def name(self): diff --git a/homeassistant/components/juicenet/translations/de.json b/homeassistant/components/juicenet/translations/de.json index fbdea4c321f..7a6b5cff541 100644 --- a/homeassistant/components/juicenet/translations/de.json +++ b/homeassistant/components/juicenet/translations/de.json @@ -13,7 +13,7 @@ "data": { "api_token": "API-Token" }, - "description": "Sie ben\u00f6tigen das API-Token von https://home.juice.net/Manage.", + "description": "Du ben\u00f6tigst das API-Token von https://home.juice.net/Manage.", "title": "Stelle eine Verbindung zu JuiceNet her" } } diff --git a/homeassistant/components/juicenet/translations/he.json b/homeassistant/components/juicenet/translations/he.json index 384ea203a51..c5a985fc64e 100644 --- a/homeassistant/components/juicenet/translations/he.json +++ b/homeassistant/components/juicenet/translations/he.json @@ -4,9 +4,9 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" }, "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py index 1e4dd0cbbca..6c82013361a 100644 --- a/homeassistant/components/kaiterra/sensor.py +++ b/homeassistant/components/kaiterra/sensor.py @@ -1,13 +1,20 @@ """Support for Kaiterra Temperature ahn Humidity Sensors.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DISPATCHER_KAITERRA, DOMAIN SENSORS = [ - {"name": "Temperature", "prop": "rtemp", "device_class": "temperature"}, - {"name": "Humidity", "prop": "rhumid", "device_class": "humidity"}, + {"name": "Temperature", "prop": "rtemp", "device_class": DEVICE_CLASS_TEMPERATURE}, + {"name": "Humidity", "prop": "rhumid", "device_class": DEVICE_CLASS_HUMIDITY}, ] diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 836785490e8..2792246d71c 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( DEVICE_CLASS_POWER, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, ) @@ -23,7 +23,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= "Max Current", "max_current", "mdi:flash", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ), KebaSensor( keba, diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index c82524a3410..fdb7dafc516 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Keenetic NDMS2.""" from __future__ import annotations +from typing import Any from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection @@ -50,7 +51,9 @@ class KeeneticFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return KeeneticOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: @@ -135,7 +138,9 @@ class KeeneticOptionsFlowHandler(config_entries.OptionsFlow): self.config_entry = config_entry self._interface_options = {} - async def async_step_init(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][ ROUTER @@ -152,7 +157,9 @@ class KeeneticOptionsFlowHandler(config_entries.OptionsFlow): } return await self.async_step_user() - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the device tracker options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/keenetic_ndms2/translations/fr.json b/homeassistant/components/keenetic_ndms2/translations/fr.json index bf3ebbf5b22..74685da509b 100644 --- a/homeassistant/components/keenetic_ndms2/translations/fr.json +++ b/homeassistant/components/keenetic_ndms2/translations/fr.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "no_udn": "Les informations de d\u00e9couverte SSDP n'ont pas d'UDN", + "not_keenetic_ndms2": "L'\u00e9l\u00e9ment d\u00e9couvert n'est pas un routeur Keenetic" }, "error": { "cannot_connect": "\u00c9chec de connexion" }, + "flow_title": "{name} ({host})", "step": { "user": { "data": { diff --git a/homeassistant/components/keenetic_ndms2/translations/hu.json b/homeassistant/components/keenetic_ndms2/translations/hu.json index c1d27e9ae07..c2327130a11 100644 --- a/homeassistant/components/keenetic_ndms2/translations/hu.json +++ b/homeassistant/components/keenetic_ndms2/translations/hu.json @@ -1,7 +1,9 @@ { "config": { "abort": { - "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "no_udn": "Az SSDP felder\u00edt\u00e9si inform\u00e1ci\u00f3knak nincs UDN-je", + "not_keenetic_ndms2": "A felfedezett elem nem Keenetic router" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" @@ -14,6 +16,21 @@ "password": "Jelsz\u00f3", "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "\u00c1ll\u00edtsa be a Keenetic NDMS2 t\u00edpus\u00fa routert" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "consider_home": "Otthoni intervallumk\u00e9nt vegye figyelembe", + "include_arp": "ARP-adatok haszn\u00e1lata (figyelmen k\u00edv\u00fcl hagyva, ha hotspot-adatokat haszn\u00e1lnak)", + "include_associated": "WiFi AP t\u00e1rs\u00edt\u00e1si adatok haszn\u00e1lata (figyelmen k\u00edv\u00fcl hagyva, ha hotspot adatokat haszn\u00e1lnak)", + "interfaces": "A beolvasni k\u00edv\u00e1nt interf\u00e9szek kiv\u00e1laszt\u00e1sa", + "scan_interval": "Szkennel\u00e9si intervallum", + "try_hotspot": "Haszn\u00e1lja az \u201eip hotspot\u201d adatokat (a legpontosabb)" } } } diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py index 732008e5780..01e584922b6 100644 --- a/homeassistant/components/kira/__init__.py +++ b/homeassistant/components/kira/__init__.py @@ -78,7 +78,7 @@ def load_codes(path): """Load KIRA codes from specified file.""" codes = [] if os.path.exists(path): - with open(path) as code_file: + with open(path, encoding="utf8") as code_file: data = yaml.safe_load(code_file) or [] for code in data: try: @@ -87,7 +87,7 @@ def load_codes(path): # keep going _LOGGER.warning("KIRA code invalid data: %s", exception) else: - with open(path, "w") as code_file: + with open(path, "w", encoding="utf8") as code_file: code_file.write("") return codes diff --git a/homeassistant/components/kmtronic/translations/hu.json b/homeassistant/components/kmtronic/translations/hu.json index 0abcc301f0c..4fe9a3875e6 100644 --- a/homeassistant/components/kmtronic/translations/hu.json +++ b/homeassistant/components/kmtronic/translations/hu.json @@ -17,5 +17,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "reverse": "Ford\u00edtott kapcsol\u00f3 logika (NC haszn\u00e1lata)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index b56331bc80c..e6c1562f37e 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -245,7 +245,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await knx_module.xknx.stop() await asyncio.gather( - *[platform.async_reset() for platform in async_get_platforms(hass, DOMAIN)] + *(platform.async_reset() for platform in async_get_platforms(hass, DOMAIN)) ) await async_setup(hass, config) diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index c2e2a269b27..803ded55441 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -185,7 +185,7 @@ 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}" # pylint: disable=protected-access + f"{self._device._setpoint_shift.group_address}" ) async def async_update(self) -> None: diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 5b92f9f1f6a..408ab25e7cc 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -141,7 +141,8 @@ class KNXExposeSensor: if new_value is None: return old_state = event.data.get("old_state") - old_value = self._get_expose_value(old_state) + # don't use default value for comparison on first state change (old_state is None) + old_value = self._get_expose_value(old_state) if old_state is not None else None # don't send same value sequentially if new_value != old_value: await self._async_set_knx_value(new_value) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 56068b5deae..b807ad1335d 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -10,11 +10,13 @@ from xknx.telegram.address import parse_device_group_address from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_HS_COLOR, ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_XY_COLOR, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, COLOR_MODE_ONOFF, COLOR_MODE_RGB, COLOR_MODE_RGBW, @@ -158,6 +160,12 @@ def _create_light(xknx: XKNX, config: ConfigType) -> XknxLight: group_address_color_state=config.get(LightSchema.CONF_COLOR_STATE_ADDRESS), group_address_rgbw=config.get(LightSchema.CONF_RGBW_ADDRESS), group_address_rgbw_state=config.get(LightSchema.CONF_RGBW_STATE_ADDRESS), + group_address_hue=config.get(LightSchema.CONF_HUE_ADDRESS), + group_address_hue_state=config.get(LightSchema.CONF_HUE_STATE_ADDRESS), + group_address_saturation=config.get(LightSchema.CONF_SATURATION_ADDRESS), + group_address_saturation_state=config.get( + LightSchema.CONF_SATURATION_STATE_ADDRESS + ), group_address_xyy_color=config.get(LightSchema.CONF_XYY_ADDRESS), group_address_xyy_color_state=config.get(LightSchema.CONF_XYY_STATE_ADDRESS), group_address_tunable_white=group_address_tunable_white, @@ -283,6 +291,13 @@ class KNXLight(KnxEntity, LightEntity): return (*rgb, white) return None + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + # Hue is scaled 0..360 int encoded in 1 byte in KNX (-> only 256 possible values) + # Saturation is scaled 0..100 int + return self._device.current_hs_color + @property def xy_color(self) -> tuple[float, float] | None: """Return the xy color value [float, float].""" @@ -315,6 +330,8 @@ class KNXLight(KnxEntity, LightEntity): """Return the color mode of the light.""" if self._device.supports_xyy_color: return COLOR_MODE_XY + if self._device.supports_hs_color: + return COLOR_MODE_HS if self._device.supports_rgbw: return COLOR_MODE_RGBW if self._device.supports_color: @@ -339,6 +356,7 @@ class KNXLight(KnxEntity, LightEntity): mireds = kwargs.get(ATTR_COLOR_TEMP) rgb = kwargs.get(ATTR_RGB_COLOR) rgbw = kwargs.get(ATTR_RGBW_COLOR) + hs_color = kwargs.get(ATTR_HS_COLOR) xy_color = kwargs.get(ATTR_XY_COLOR) if ( @@ -347,6 +365,7 @@ class KNXLight(KnxEntity, LightEntity): and mireds is None and rgb is None and rgbw is None + and hs_color is None and xy_color is None ): await self._device.set_on() @@ -396,6 +415,12 @@ class KNXLight(KnxEntity, LightEntity): ) return + if hs_color is not None: + # round so only one telegram will be sent if the other matches state + hue = round(hs_color[0]) + sat = round(hs_color[1]) + await self._device.set_hs_color((hue, sat)) + if brightness is not None: # brightness: 1..255; 0 brightness will call async_turn_off() if self._device.brightness.writable: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index e075411d8b8..c514ec6fe64 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -2,7 +2,7 @@ "domain": "knx", "name": "KNX", "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.18.8"], + "requirements": ["xknx==0.18.9"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "silver", "iot_class": "local_push" diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 079dc7363bf..11b2504d129 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -496,8 +496,12 @@ class LightSchema(KNXPlatformSchema): CONF_COLOR_TEMP_ADDRESS = "color_temperature_address" CONF_COLOR_TEMP_STATE_ADDRESS = "color_temperature_state_address" CONF_COLOR_TEMP_MODE = "color_temperature_mode" + CONF_HUE_ADDRESS = "hue_address" + CONF_HUE_STATE_ADDRESS = "hue_state_address" CONF_RGBW_ADDRESS = "rgbw_address" CONF_RGBW_STATE_ADDRESS = "rgbw_state_address" + CONF_SATURATION_ADDRESS = "saturation_address" + CONF_SATURATION_STATE_ADDRESS = "saturation_state_address" CONF_XYY_ADDRESS = "xyy_address" CONF_XYY_STATE_ADDRESS = "xyy_state_address" CONF_MIN_KELVIN = "min_kelvin" @@ -514,7 +518,18 @@ class LightSchema(KNXPlatformSchema): CONF_BLUE = "blue" CONF_WHITE = "white" - COLOR_SCHEMA = vol.Schema( + _hs_color_inclusion_msg = ( + "'hue_address', 'saturation_address' and 'brightness_address'" + " are required for hs_color configuration" + ) + HS_COLOR_SCHEMA = { + vol.Optional(CONF_HUE_ADDRESS): ga_list_validator, + vol.Optional(CONF_HUE_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_SATURATION_ADDRESS): ga_list_validator, + vol.Optional(CONF_SATURATION_STATE_ADDRESS): ga_list_validator, + } + + INDIVIDUAL_COLOR_SCHEMA = vol.Schema( { vol.Optional(KNX_ADDRESS): ga_list_validator, vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, @@ -536,18 +551,18 @@ class LightSchema(KNXPlatformSchema): CONF_RED, "individual_colors", msg="'red', 'green' and 'blue' are required for individual colors configuration", - ): COLOR_SCHEMA, + ): INDIVIDUAL_COLOR_SCHEMA, vol.Inclusive( CONF_GREEN, "individual_colors", msg="'red', 'green' and 'blue' are required for individual colors configuration", - ): COLOR_SCHEMA, + ): INDIVIDUAL_COLOR_SCHEMA, vol.Inclusive( CONF_BLUE, "individual_colors", msg="'red', 'green' and 'blue' are required for individual colors configuration", - ): COLOR_SCHEMA, - vol.Optional(CONF_WHITE): COLOR_SCHEMA, + ): INDIVIDUAL_COLOR_SCHEMA, + vol.Optional(CONF_WHITE): INDIVIDUAL_COLOR_SCHEMA, }, vol.Exclusive(CONF_COLOR_ADDRESS, "color"): ga_list_validator, vol.Optional(CONF_COLOR_STATE_ADDRESS): ga_list_validator, @@ -556,6 +571,7 @@ class LightSchema(KNXPlatformSchema): vol.Optional( CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE ): vol.All(vol.Upper, cv.enum(ColorTempModes)), + **HS_COLOR_SCHEMA, vol.Exclusive(CONF_RGBW_ADDRESS, "color"): ga_list_validator, vol.Optional(CONF_RGBW_STATE_ADDRESS): ga_list_validator, vol.Exclusive(CONF_XYY_ADDRESS, "color"): ga_list_validator, @@ -569,20 +585,39 @@ class LightSchema(KNXPlatformSchema): } ), vol.Any( - # either global "address" or "individual_colors" is required vol.Schema( + {vol.Required(KNX_ADDRESS): object}, + extra=vol.ALLOW_EXTRA, + ), + vol.Schema( # brightness addresses are required in INDIVIDUAL_COLOR_SCHEMA + {vol.Required(CONF_INDIVIDUAL_COLORS): object}, + extra=vol.ALLOW_EXTRA, + ), + msg="either 'address' or 'individual_colors' is required", + ), + vol.Any( + vol.Schema( # 'brightness' is non-optional for hs-color { - # brightness addresses are required in COLOR_SCHEMA - vol.Required(CONF_INDIVIDUAL_COLORS): object, + vol.Inclusive( + CONF_BRIGHTNESS_ADDRESS, "hs_color", msg=_hs_color_inclusion_msg + ): object, + vol.Inclusive( + CONF_HUE_ADDRESS, "hs_color", msg=_hs_color_inclusion_msg + ): object, + vol.Inclusive( + CONF_SATURATION_ADDRESS, "hs_color", msg=_hs_color_inclusion_msg + ): object, }, extra=vol.ALLOW_EXTRA, ), - vol.Schema( + vol.Schema( # hs-colors not used { - vol.Required(KNX_ADDRESS): object, + vol.Optional(CONF_HUE_ADDRESS): None, + vol.Optional(CONF_SATURATION_ADDRESS): None, }, extra=vol.ALLOW_EXTRA, ), + msg=_hs_color_inclusion_msg, ), ) diff --git a/homeassistant/components/kodi/browse_media.py b/homeassistant/components/kodi/browse_media.py index a7e87a6ae27..c36f05fc0db 100644 --- a/homeassistant/components/kodi/browse_media.py +++ b/homeassistant/components/kodi/browse_media.py @@ -71,7 +71,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None): return None children = await asyncio.gather( - *[item_payload(item, get_thumbnail_url) for item in media] + *(item_payload(item, get_thumbnail_url) for item in media) ) if search_type in (MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE) and search_id == "": @@ -209,7 +209,7 @@ async def library_payload(): } library_info.children = await asyncio.gather( - *[ + *( item_payload( { "label": item["label"], @@ -220,7 +220,7 @@ async def library_payload(): for item in [ {"label": name, "type": type_} for type_, name in library.items() ] - ] + ) ) return library_info diff --git a/homeassistant/components/kodi/translations/de.json b/homeassistant/components/kodi/translations/de.json index 80d47751006..478fa2c32e5 100644 --- a/homeassistant/components/kodi/translations/de.json +++ b/homeassistant/components/kodi/translations/de.json @@ -29,15 +29,15 @@ "data": { "host": "Host", "port": "Port", - "ssl": "Verwendet ein SSL Zertifikat" + "ssl": "Verwendet ein SSL-Zertifikat" }, - "description": "Kodi-Verbindungsinformationen. Bitte stellen Sie sicher, dass Sie \"Steuerung von Kodi \u00fcber HTTP zulassen\" in System/Einstellungen/Netzwerk/Dienste aktivieren." + "description": "Kodi-Verbindungsinformationen. Bitte stelle sicher, dass du \"Steuerung von Kodi \u00fcber HTTP zulassen\" in System/Einstellungen/Netzwerk/Dienste aktiviert hast." }, "ws_port": { "data": { "ws_port": "Port" }, - "description": "Der WebSocket-Port (in Kodi manchmal TCP-Port genannt). Um eine Verbindung \u00fcber WebSocket herzustellen, m\u00fcssen Sie unter System/Einstellungen/Netzwerk/Dienste \"Programme ... zur Steuerung von Kodi zulassen\" aktivieren. Wenn WebSocket nicht aktiviert ist, entfernen Sie den Port und lassen ihn leer." + "description": "Der WebSocket-Port (in Kodi manchmal TCP-Port genannt). Um eine Verbindung \u00fcber WebSocket herzustellen, musst du unter System/Einstellungen/Netzwerk/Dienste \"Programme ... zur Steuerung von Kodi zulassen\" aktivieren. Wenn WebSocket nicht aktiviert ist, entferne den Port und lasse ihn leer." } } }, diff --git a/homeassistant/components/kodi/translations/he.json b/homeassistant/components/kodi/translations/he.json index 07d8838f200..5b992705068 100644 --- a/homeassistant/components/kodi/translations/he.json +++ b/homeassistant/components/kodi/translations/he.json @@ -4,6 +4,7 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "no_uuid": "\u05dc\u05de\u05d5\u05e4\u05e2 Kodi \u05d0\u05d9\u05df \u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05d7\u05d5\u05d3\u05d9. \u05d4\u05e1\u05d9\u05d1\u05d4 \u05dc\u05db\u05da \u05d4\u05d9\u05d0 \u05db\u05db\u05dc \u05d4\u05e0\u05e8\u05d0\u05d4 \u05d2\u05e8\u05e1\u05ea \u05e7\u05d5\u05d3\u05d9 \u05d9\u05e9\u05e0\u05d4 (17.x \u05d5\u05de\u05d8\u05d4). \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea\u05da \u05dc\u05e7\u05d1\u05d5\u05e2 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1 \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9 \u05d0\u05d5 \u05dc\u05e9\u05d3\u05e8\u05d2 \u05dc\u05d2\u05d9\u05e8\u05e1\u05ea Kodi \u05e2\u05d3\u05db\u05e0\u05d9\u05ea \u05d9\u05d5\u05ea\u05e8.", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "error": { @@ -20,18 +21,30 @@ }, "description": "\u05e0\u05d0 \u05d4\u05d6\u05df \u05d0\u05ea \u05e9\u05dd \u05d4\u05de\u05e9\u05ea\u05de\u05e9 \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05d1-Kodi. \u05e0\u05d9\u05ea\u05df \u05dc\u05de\u05e6\u05d5\u05d0 \u05d0\u05d5\u05ea\u05dd \u05d1\u05de\u05e2\u05e8\u05db\u05ea/\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea/\u05e8\u05e9\u05ea/\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9\u05dd." }, + "discovery_confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9 (`{name}`) \u05dc-Home Assistant?", + "title": "\u05d2\u05d9\u05dc\u05d4 \u05d0\u05ea \u05e7\u05d5\u05d3\u05d9" + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7", "port": "\u05e4\u05ea\u05d7\u05d4", "ssl": "\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05d9\u05e9\u05d5\u05e8 SSL" - } + }, + "description": "\u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05d7\u05d9\u05d1\u05d5\u05e8 Kodi. \u05d9\u05e9 \u05dc\u05d4\u05e7\u05e4\u05d9\u05d3 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \"\u05d0\u05e4\u05e9\u05e8 \u05e9\u05dc\u05d9\u05d8\u05d4 \u05d1\u05e7\u05d5\u05d3\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea HTTP\" \u05d1\u05de\u05e2\u05e8\u05db\u05ea/\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea/\u05e8\u05e9\u05ea/\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9\u05dd." }, "ws_port": { "data": { "ws_port": "\u05e4\u05ea\u05d7\u05d4" - } + }, + "description": "\u05d9\u05e6\u05d9\u05d0\u05ea WebSocket (\u05e0\u05e7\u05e8\u05d0\u05ea \u05dc\u05e4\u05e2\u05de\u05d9\u05dd \u05d1\u05e7\u05d5\u05d3\u05d9 \u05d9\u05e6\u05d9\u05d0\u05ea TCP). \u05e2\u05dc \u05de\u05e0\u05ea \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05d3\u05e8\u05da WebSocket, \u05e2\u05dc\u05d9\u05da \u05dc\u05d0\u05e4\u05e9\u05e8 \"\u05d0\u05e4\u05e9\u05e8 \u05dc\u05ea\u05d5\u05db\u05e0\u05d9\u05d5\u05ea ... \u05dc\u05e9\u05dc\u05d5\u05d8 \u05d1\u05e7\u05d5\u05d3\u05d9\" \u05d1\u05de\u05e2\u05e8\u05db\u05ea/\u05d4\u05d2\u05d3\u05e8\u05d5\u05ea/\u05e8\u05e9\u05ea/\u05e9\u05d9\u05e8\u05d5\u05ea\u05d9\u05dd. \u05d0\u05dd WebSocket \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc, \u05d4\u05e1\u05e8 \u05d0\u05ea \u05d4\u05d9\u05e6\u05d9\u05d0\u05d4 \u05d5\u05d4\u05e9\u05d0\u05d9\u05e8 \u05e8\u05d9\u05e7." } } + }, + "device_automation": { + "trigger_type": { + "turn_off": "{entity_name} \u05d4\u05ea\u05d1\u05e7\u05e9 \u05dc\u05db\u05d1\u05d5\u05ea", + "turn_on": "{entity_name} \u05d4\u05ea\u05d1\u05e7\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc" + } } } \ No newline at end of file diff --git a/homeassistant/components/kodi/translations/hu.json b/homeassistant/components/kodi/translations/hu.json index 48ea9d954bd..9ae1e0741d5 100644 --- a/homeassistant/components/kodi/translations/hu.json +++ b/homeassistant/components/kodi/translations/hu.json @@ -4,6 +4,7 @@ "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "no_uuid": "A Kodi p\u00e9ld\u00e1nynak nincs egyedi azonos\u00edt\u00f3ja. Ennek oka val\u00f3sz\u00edn\u0171leg egy r\u00e9gi Kodi verzi\u00f3 (17.x vagy alacsonyabb). Be\u00e1ll\u00edthatja manu\u00e1lisan az integr\u00e1ci\u00f3t, vagy friss\u00edthet egy \u00fajabb Kodi verzi\u00f3ra.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { @@ -29,13 +30,21 @@ "host": "Hoszt", "port": "Port", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata" - } + }, + "description": "Kodi csatlakoz\u00e1si inform\u00e1ci\u00f3k. A Rendszer / Be\u00e1ll\u00edt\u00e1sok / H\u00e1l\u00f3zat / Szolg\u00e1ltat\u00e1sok men\u00fcben enged\u00e9lyezze a \"Kodi vez\u00e9rl\u00e9s\u00e9nek enged\u00e9lyez\u00e9se HTTP-n kereszt\u00fcl\" lehet\u0151s\u00e9get." }, "ws_port": { "data": { "ws_port": "Port" - } + }, + "description": "A WebSocket port (n\u00e9ha TCP-portnak h\u00edvj\u00e1k a Kodi-ban). A WebSocketen kereszt\u00fcli kapcsol\u00f3d\u00e1shoz enged\u00e9lyeznie kell a \"Programok enged\u00e9lyez\u00e9se ... a Kodi vez\u00e9rl\u00e9s\u00e9t\" lehet\u0151s\u00e9get a Rendszer / Be\u00e1ll\u00edt\u00e1sok / H\u00e1l\u00f3zat / Szolg\u00e1ltat\u00e1sok men\u00fcben. Ha a WebSocket nincs enged\u00e9lyezve, t\u00e1vol\u00edtsa el a portot, \u00e9s hagyja \u00fcresen." } } + }, + "device_automation": { + "trigger_type": { + "turn_off": "{entity_name} kikapcsol\u00e1s\u00e1t k\u00e9rt\u00e9k", + "turn_on": "{entity_name} bekapcsol\u00e1s\u00e1t k\u00e9rt\u00e9k" + } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 4b5890532d1..32d0f0e20c0 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -368,7 +368,7 @@ class KonnectedView(HomeAssistantView): zone_data["device_id"] = device_id - for attr in ["state", "temp", "humi", "addr"]: + for attr in ("state", "temp", "humi", "addr"): value = payload.get(attr) handler = HANDLERS.get(attr) if value is not None and handler: diff --git a/homeassistant/components/konnected/translations/ar.json b/homeassistant/components/konnected/translations/ar.json new file mode 100644 index 00000000000..9b79d82eaff --- /dev/null +++ b/homeassistant/components/konnected/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "options_io_ext": { + "data": { + "alarm1": "\u0625\u0646\u0630\u0627\u0631 1" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/translations/de.json b/homeassistant/components/konnected/translations/de.json index 98862e85a8b..fd307e90f20 100644 --- a/homeassistant/components/konnected/translations/de.json +++ b/homeassistant/components/konnected/translations/de.json @@ -11,11 +11,11 @@ }, "step": { "confirm": { - "description": "Modell: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nSie k\u00f6nnen das I / O - und Bedienfeldverhalten in den Einstellungen der verbundenen Alarmzentrale konfigurieren.", + "description": "Modell: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nDu kannst das I / O - und Bedienfeldverhalten in den Einstellungen der verbundenen Alarmzentrale konfigurieren.", "title": "Konnected Device Bereit" }, "import_confirm": { - "description": "Ein Konnected Alarm Panel mit der ID {id} wurde in configuration.yaml entdeckt. Mit diesem Ablauf k\u00f6nnen Sie ihn in einen Konfigurationseintrag importieren.", + "description": "Ein Konnected Alarm Panel mit der ID {id} wurde in configuration.yaml entdeckt. Mit diesem Ablauf kannst du ihn in einen Konfigurationseintrag importieren.", "title": "Importieren von Konnected Ger\u00e4t" }, "user": { @@ -23,7 +23,7 @@ "host": "IP-Adresse", "port": "Port" }, - "description": "Bitte geben Sie die Hostinformationen f\u00fcr Ihr Konnected Panel ein." + "description": "Bitte gib die Hostinformationen f\u00fcr dein Konnected Panel ein." } } }, @@ -39,12 +39,12 @@ "step": { "options_binary": { "data": { - "inverse": "Invertieren Sie den \u00d6ffnungs- / Schlie\u00dfzustand", + "inverse": "Invertiere den \u00d6ffnungs- / Schlie\u00dfzustand", "name": "Name (optional)", "type": "Bin\u00e4rer Sensortyp" }, "description": "Bitte w\u00e4hle die Optionen f\u00fcr den an {zone} angeschlossenen Bin\u00e4rsensor", - "title": "Konfigurieren Sie den Bin\u00e4rsensor" + "title": "Konfiguriere den Bin\u00e4rsensor" }, "options_digital": { "data": { @@ -53,7 +53,7 @@ "type": "Sensortyp" }, "description": "Bitte w\u00e4hle die Optionen f\u00fcr den an {zone} angeschlossenen digitalen Sensor aus", - "title": "Konfigurieren Sie den digitalen Sensor" + "title": "Konfiguriere den digitalen Sensor" }, "options_io": { "data": { @@ -66,7 +66,7 @@ "7": "Zone 7", "out": "OUT" }, - "description": "Es wurde ein {model} bei {host} entdeckt. W\u00e4hlen Sie unten die Basiskonfiguration der einzelnen E / A aus. Je nach E / A k\u00f6nnen bin\u00e4re Sensoren (Kontakte \u00f6ffnen / schlie\u00dfen), digitale Sensoren (dht und ds18b20) oder umschaltbare Ausg\u00e4nge verwendet werden. In den n\u00e4chsten Schritten k\u00f6nnen Sie detaillierte Optionen konfigurieren.", + "description": "Es wurde ein {model} bei {host} entdeckt. W\u00e4hle unten die Basiskonfiguration der einzelnen E / A aus. Je nach E / A k\u00f6nnen bin\u00e4re Sensoren (Kontakte \u00f6ffnen / schlie\u00dfen), digitale Sensoren (dht und ds18b20) oder umschaltbare Ausg\u00e4nge verwendet werden. In den n\u00e4chsten Schritten kannst du detaillierte Optionen konfigurieren.", "title": "Konfigurieren von I/O" }, "options_io_ext": { @@ -80,30 +80,30 @@ "alarm2_out2": "OUT2/ALARM2", "out1": "OUT1" }, - "description": "W\u00e4hlen Sie unten die Konfiguration der verbleibenden E / A. In den n\u00e4chsten Schritten k\u00f6nnen Sie detaillierte Optionen konfigurieren.", - "title": "Konfigurieren Sie Erweiterte I/O" + "description": "W\u00e4hle unten die Konfiguration der verbleibenden E / A. In den n\u00e4chsten Schritten kannst du detaillierte Optionen konfigurieren.", + "title": "Konfiguriere Erweiterte I/O" }, "options_misc": { "data": { "api_host": "API-Host-URL \u00fcberschreiben (optional)", "blink": "LED Panel blinkt beim senden von Status\u00e4nderungen", - "discovery": "Reagieren auf Suchanfragen in Ihrem Netzwerk", - "override_api_host": "\u00dcberschreiben Sie die Standard-Host-Panel-URL der Home Assistant-API" + "discovery": "Reagieren auf Suchanfragen in deinem Netzwerk", + "override_api_host": "\u00dcberschreibe die Standard-Host-Panel-URL der Home Assistant-API" }, - "description": "Bitte w\u00e4hlen Sie das gew\u00fcnschte Verhalten f\u00fcr Ihr Panel", + "description": "Bitte w\u00e4hle das gew\u00fcnschte Verhalten f\u00fcr dein Panel", "title": "Sonstiges konfigurieren" }, "options_switch": { "data": { "activation": "Ausgabe, wenn eingeschaltet", "momentary": "Impulsdauer (ms) (optional)", - "more_states": "Konfigurieren Sie zus\u00e4tzliche Zust\u00e4nde f\u00fcr diese Zone", + "more_states": "Konfiguriere zus\u00e4tzliche Zust\u00e4nde f\u00fcr diese Zone", "name": "Name (optional)", "pause": "Pause zwischen Impulsen (ms) (optional)", "repeat": "Mal wiederholen (-1 = unendlich) (optional)" }, "description": "Bitte w\u00e4hlen die Ausgabeoptionen f\u00fcr {zone} : Status {state}", - "title": "Konfigurieren Sie den schaltbaren Ausgang" + "title": "Konfiguriere den schaltbaren Ausgang" } } } diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index c31f537e698..0a436bc2d3c 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -22,9 +22,30 @@ } }, "options": { + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "step": { + "options_binary": { + "data": { + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + } + }, + "options_digital": { + "data": { + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + } + }, "options_io": { "description": "\u05d4\u05ea\u05d2\u05dc\u05d4 {model} \u05d1-{host} . \u05d1\u05d7\u05e8 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d1\u05e1\u05d9\u05e1 \u05e9\u05dc \u05db\u05dc \u05e7\u05dc\u05d8/\u05e4\u05dc\u05d8 \u05dc\u05de\u05d8\u05d4 - \u05d1\u05d4\u05ea\u05d0\u05dd \u05dc\u05e7\u05dc\u05d8/\u05e4\u05dc\u05d8 \u05d6\u05d4 \u05e2\u05e9\u05d5\u05d9 \u05dc\u05d0\u05e4\u05e9\u05e8 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d1\u05d9\u05e0\u05d0\u05e8\u05d9\u05d9\u05dd (\u05de\u05d2\u05e2\u05d9\u05dd \u05e4\u05ea\u05d5\u05d7\u05d9\u05dd/\u05e1\u05d2\u05d5\u05e8\u05d9\u05dd), \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d3\u05d9\u05d2\u05d9\u05d8\u05dc\u05d9\u05d9\u05dd (dht \u05d5-ds18b20), \u05d0\u05d5 \u05d9\u05e6\u05d9\u05d0\u05d5\u05ea \u05e0\u05d9\u05ea\u05e0\u05d5\u05ea \u05dc\u05d4\u05d7\u05dc\u05e4\u05d4. \u05ea\u05d5\u05db\u05dc \u05dc\u05e7\u05d1\u05d5\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05de\u05e4\u05d5\u05e8\u05d8\u05d5\u05ea \u05d1\u05e9\u05dc\u05d1\u05d9\u05dd \u05d4\u05d1\u05d0\u05d9\u05dd." + }, + "options_switch": { + "data": { + "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + } } } } diff --git a/homeassistant/components/kostal_plenticore/strings.json b/homeassistant/components/kostal_plenticore/strings.json index 771c3ada744..30ce5af5a6c 100644 --- a/homeassistant/components/kostal_plenticore/strings.json +++ b/homeassistant/components/kostal_plenticore/strings.json @@ -1,5 +1,4 @@ { - "title": "Kostal Plenticore Solar Inverter", "config": { "step": { "user": { @@ -18,4 +17,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/kostal_plenticore/translations/hu.json b/homeassistant/components/kostal_plenticore/translations/hu.json new file mode 100644 index 00000000000..b235578e9c3 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p", + "password": "Jelsz\u00f3" + } + } + } + }, + "title": "Kostal Plenticore szol\u00e1r inverter" +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/ar.json b/homeassistant/components/kraken/translations/ar.json new file mode 100644 index 00000000000..2c2b892acee --- /dev/null +++ b/homeassistant/components/kraken/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0641\u062a\u0631\u0629 \u062a\u0643\u0631\u0627\u0631 \u0627\u0644\u062a\u062d\u062f\u064a\u062b" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/de.json b/homeassistant/components/kraken/translations/de.json index d0a845edfd4..5a6df75c402 100644 --- a/homeassistant/components/kraken/translations/de.json +++ b/homeassistant/components/kraken/translations/de.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, diff --git a/homeassistant/components/kraken/translations/fr.json b/homeassistant/components/kraken/translations/fr.json new file mode 100644 index 00000000000..1aa7fdfbf54 --- /dev/null +++ b/homeassistant/components/kraken/translations/fr.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "error": { + "one": "UN", + "other": "AUTRE" + }, + "step": { + "user": { + "data": { + "one": "UN", + "other": "AUTRE" + }, + "description": "Voulez-vous commencer la configuration\u00a0?" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle de mise \u00e0 jour", + "tracked_asset_pairs": "Paires d'actifs suivis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/he.json b/homeassistant/components/kraken/translations/he.json index 4676729e600..2be0837c966 100644 --- a/homeassistant/components/kraken/translations/he.json +++ b/homeassistant/components/kraken/translations/he.json @@ -3,10 +3,31 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "error": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "step": { "user": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u05de\u05e8\u05d5\u05d5\u05d7 \u05d6\u05de\u05df \u05dc\u05e2\u05d3\u05db\u05d5\u05df" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/kraken/translations/hu.json b/homeassistant/components/kraken/translations/hu.json index 4901da74d90..793a3433eb8 100644 --- a/homeassistant/components/kraken/translations/hu.json +++ b/homeassistant/components/kraken/translations/hu.json @@ -3,10 +3,28 @@ "abort": { "already_configured": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, + "error": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "step": { "user": { + "data": { + "one": "\u00dcres", + "other": "\u00dcres" + }, "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si intervallum", + "tracked_asset_pairs": "Nyomon k\u00f6vetett eszk\u00f6zp\u00e1rok" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index fd907235b45..6e04dbdfcfd 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -142,7 +142,6 @@ class KulerskyLight(LightEntity): try: if not self._available: await self._light.connect() - # pylint: disable=invalid-name rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: if self._available: diff --git a/homeassistant/components/kulersky/translations/de.json b/homeassistant/components/kulersky/translations/de.json index 86bc8e36730..19cd4b8c70e 100644 --- a/homeassistant/components/kulersky/translations/de.json +++ b/homeassistant/components/kulersky/translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index 7c5557757ef..2f93196a4bb 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_NAME, CONF_SENSORS, CONF_TYPE, + DEVICE_CLASS_TEMPERATURE, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, TEMP_CELSIUS, @@ -174,6 +175,7 @@ class LaCrosseSensor(SensorEntity): class LaCrosseTemperature(LaCrosseSensor): """Implementation of a Lacrosse temperature sensor.""" + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_unit_of_measurement = TEMP_CELSIUS @property diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 75fd91c28f5..9db564812a8 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -1,5 +1,8 @@ """Support for LCN devices.""" +from __future__ import annotations + import logging +from typing import Callable import pypck @@ -12,18 +15,25 @@ from homeassistant.const import ( CONF_RESOURCE, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, CONNECTION, DOMAIN, PLATFORMS -from .helpers import generate_unique_id, import_lcn_config +from .helpers import ( + DeviceConnectionType, + InputType, + generate_unique_id, + import_lcn_config, +) from .schemas import CONFIG_SCHEMA # noqa: F401 from .services import SERVICES _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LCN component.""" if DOMAIN not in config: return True @@ -43,7 +53,9 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: """Set up a connection to PCHK host from a config entry.""" hass.data.setdefault(DOMAIN, {}) if config_entry.entry_id in hass.data[DOMAIN]: @@ -104,7 +116,9 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms unload_ok = await hass.config_entries.async_unload_platforms( @@ -126,16 +140,18 @@ async def async_unload_entry(hass, config_entry): class LcnEntity(Entity): """Parent class for all entities associated with the LCN component.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN device.""" self.config = config self.entry_id = entry_id self.device_connection = device_connection - self._unregister_for_inputs = None - self._name = config[CONF_NAME] + self._unregister_for_inputs: Callable | None = None + self._name: str = config[CONF_NAME] @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" unique_device_id = generate_unique_id( ( @@ -147,26 +163,26 @@ class LcnEntity(Entity): return f"{self.entry_id}-{unique_device_id}-{self.config[CONF_RESOURCE]}" @property - def should_poll(self): + def should_poll(self) -> bool: """Lcn device entity pushes its state to HA.""" return False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" if not self.device_connection.is_group: self._unregister_for_inputs = self.device_connection.register_for_inputs( self.input_received ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" if self._unregister_for_inputs is not None: self._unregister_for_inputs() @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set state/value when LCN input object (command) is received.""" diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 3bea502cc76..13a2a5b3bb3 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -1,21 +1,29 @@ """Support for LCN binary sensors.""" +from __future__ import annotations + import pypck from homeassistant.components.binary_sensor import ( DOMAIN as DOMAIN_BINARY_SENSOR, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, SETPOINTS -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection -def create_lcn_binary_sensor_entity(hass, entity_config, config_entry): +def create_lcn_binary_sensor_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in SETPOINTS: @@ -28,7 +36,11 @@ def create_lcn_binary_sensor_entity(hass, entity_config, config_entry): return LcnLockKeysSensor(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN switch entities from a config entry.""" entities = [] @@ -44,7 +56,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for regulator locks.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, entry_id, device_connection) @@ -54,7 +68,7 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): self._value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: @@ -62,7 +76,7 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): self.setpoint_variable ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: @@ -71,11 +85,11 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._value - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusVar) @@ -90,7 +104,9 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, entry_id, device_connection) @@ -100,7 +116,7 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): self._value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: @@ -108,7 +124,7 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): self.bin_sensor_port ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: @@ -117,11 +133,11 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._value - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return @@ -133,31 +149,33 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN sensor for key locks.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN sensor.""" super().__init__(config, entry_id, device_connection) self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] self._value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.source) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self._value - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusKeyLocks) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index 056abcda2b0..4254a5e5480 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -1,4 +1,8 @@ """Support for LCN climate control.""" +from __future__ import annotations + +from typing import Any, cast + import pypck from homeassistant.components.climate import ( @@ -6,6 +10,7 @@ from homeassistant.components.climate import ( ClimateEntity, const, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_ADDRESS, @@ -13,7 +18,12 @@ from homeassistant.const import ( CONF_ENTITIES, CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( @@ -23,21 +33,27 @@ from .const import ( CONF_MIN_TEMP, CONF_SETPOINT, ) -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 -def create_lcn_climate_entity(hass, entity_config, config_entry): +def create_lcn_climate_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) return LcnClimate(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN switch entities from a config entry.""" entities = [] @@ -53,7 +69,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize of a LCN climate device.""" super().__init__(config, entry_id, device_connection) @@ -72,14 +90,14 @@ class LcnClimate(LcnEntity, ClimateEntity): self._target_temperature = None self._is_on = True - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.variable) await self.device_connection.activate_status_request_handler(self.setpoint) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: @@ -87,27 +105,30 @@ class LcnClimate(LcnEntity, ClimateEntity): await self.device_connection.cancel_status_request_handler(self.setpoint) @property - def supported_features(self): + def supported_features(self) -> int: """Return the list of supported features.""" return const.SUPPORT_TARGET_TEMPERATURE @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" - return self.unit.value + # Config schema only allows for: TEMP_CELSIUS and TEMP_FAHRENHEIT + if self.unit == pypck.lcn_defs.VarUnit.FAHRENHEIT: + return TEMP_FAHRENHEIT + return TEMP_CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode. Need to be one of HVAC_MODE_*. @@ -117,7 +138,7 @@ class LcnClimate(LcnEntity, ClimateEntity): return const.HVAC_MODE_OFF @property - def hvac_modes(self): + def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes. Need to be a subset of HVAC_MODES. @@ -128,16 +149,16 @@ class LcnClimate(LcnEntity, ClimateEntity): return modes @property - def max_temp(self): + def max_temp(self) -> float: """Return the maximum temperature.""" - return self._max_temp + return cast(float, self._max_temp) @property - def min_temp(self): + def min_temp(self) -> float: """Return the minimum temperature.""" - return self._min_temp + return cast(float, self._min_temp) - async def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode == const.HVAC_MODE_HEAT: if not await self.device_connection.lock_regulator( @@ -153,7 +174,7 @@ class LcnClimate(LcnEntity, ClimateEntity): self._target_temperature = None self.async_write_ha_state() - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: @@ -166,7 +187,7 @@ class LcnClimate(LcnEntity, ClimateEntity): self._target_temperature = temperature self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set temperature value when LCN input object is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusVar): return diff --git a/homeassistant/components/lcn/config_flow.py b/homeassistant/components/lcn/config_flow.py index 698a4dcedfe..905da4d005c 100644 --- a/homeassistant/components/lcn/config_flow.py +++ b/homeassistant/components/lcn/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure the LCN integration.""" +from __future__ import annotations + import logging import pypck @@ -11,13 +13,18 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import ConfigType from .const import CONF_DIM_MODE, CONF_SK_NUM_TRIES, DOMAIN _LOGGER = logging.getLogger(__name__) -def get_config_entry(hass, data): +def get_config_entry( + hass: HomeAssistant, data: ConfigType +) -> config_entries.ConfigEntry | None: """Check config entries for already configured entries based on the ip address/port.""" return next( ( @@ -30,7 +37,7 @@ def get_config_entry(hass, data): ) -async def validate_connection(host_name, data): +async def validate_connection(host_name: str, data: ConfigType) -> ConfigType: """Validate if a connection to LCN can be established.""" host = data[CONF_IP_ADDRESS] port = data[CONF_PORT] @@ -62,7 +69,7 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, data): + async def async_step_import(self, data: ConfigType) -> FlowResult: """Import existing configuration from LCN.""" host_name = data[CONF_HOST] # validate the imported connection parameters diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index 3458c78f853..faef86dc70a 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -3,11 +3,11 @@ from itertools import product from homeassistant.const import ( DEGREE, + ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, - VOLT, ) PLATFORMS = ["binary_sensor", "climate", "cover", "light", "scene", "sensor", "switch"] @@ -171,7 +171,7 @@ VAR_UNITS = [ "PERCENT", "PPM", "VOLT", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "AMPERE", "AMP", "A", diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index bf777ad93f2..bc83da55888 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -1,21 +1,30 @@ """Support for LCN covers.""" +from __future__ import annotations + +from typing import Any import pypck from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import CONF_DOMAIN_DATA, CONF_MOTOR, CONF_REVERSE_TIME -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 -def create_lcn_cover_entity(hass, entity_config, config_entry): +def create_lcn_cover_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) if entity_config[CONF_DOMAIN_DATA][CONF_MOTOR] in "OUTPUTS": @@ -24,7 +33,11 @@ def create_lcn_cover_entity(hass, entity_config, config_entry): return LcnRelayCover(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN cover entities from a config entry.""" entities = [] @@ -38,7 +51,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnOutputsCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to output ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN cover.""" super().__init__(config, entry_id, device_connection) @@ -57,7 +72,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): self._is_closing = False self._is_opening = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: @@ -68,7 +83,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): pypck.lcn_defs.OutputPort["OUTPUTDOWN"] ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: @@ -80,26 +95,26 @@ class LcnOutputsCover(LcnEntity, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._is_closed @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._is_opening @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._is_closing @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN if not await self.device_connection.control_motors_outputs( @@ -110,7 +125,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): self._is_closing = True self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" state = pypck.lcn_defs.MotorStateModifier.UP if not await self.device_connection.control_motors_outputs( @@ -122,7 +137,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): self._is_closing = False self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" state = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_outputs(state): @@ -131,7 +146,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): self._is_opening = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusOutput) @@ -159,7 +174,9 @@ class LcnOutputsCover(LcnEntity, CoverEntity): class LcnRelayCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to relays.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN cover.""" super().__init__(config, entry_id, device_connection) @@ -171,39 +188,39 @@ class LcnRelayCover(LcnEntity, CoverEntity): self._is_closing = False self._is_opening = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.motor) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.motor) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._is_closed @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._is_opening @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._is_closing @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN @@ -213,7 +230,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): self._is_closing = True self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP @@ -224,7 +241,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): self._is_closing = False self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP @@ -234,7 +251,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): self._is_opening = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set cover states when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 0687491b052..53026d0294c 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -1,9 +1,13 @@ """Helpers for LCN component.""" +from __future__ import annotations + import re +from typing import Tuple, Type, Union, cast import pypck import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, CONF_BINARY_SENSORS, @@ -21,6 +25,8 @@ from homeassistant.const import ( CONF_SWITCHES, CONF_USERNAME, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CLIMATES, @@ -38,6 +44,13 @@ from .const import ( DOMAIN, ) +# typing +AddressType = Tuple[int, int, bool] +DeviceConnectionType = Union[ + pypck.module.ModuleConnection, pypck.module.GroupConnection +] +InputType = Type[pypck.inputs.Input] + # Regex for address validation PATTERN_ADDRESS = re.compile( "^((?P\\w+)\\.)?s?(?P\\d+)\\.(?Pm|g)?(?P\\d+)$" @@ -55,21 +68,23 @@ DOMAIN_LOOKUP = { } -def get_device_connection(hass, address, config_entry): +def get_device_connection( + hass: HomeAssistant, address: AddressType, config_entry: ConfigEntry +) -> DeviceConnectionType | None: """Return a lcn device_connection.""" host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] addr = pypck.lcn_addr.LcnAddr(*address) return host_connection.get_address_conn(addr) -def get_resource(domain_name, domain_data): +def get_resource(domain_name: str, domain_data: ConfigType) -> str: """Return the resource for the specified domain_data.""" if domain_name in ["switch", "light"]: - return domain_data["output"] + return cast(str, domain_data["output"]) if domain_name in ["binary_sensor", "sensor"]: - return domain_data["source"] + return cast(str, domain_data["source"]) if domain_name == "cover": - return domain_data["motor"] + return cast(str, domain_data["motor"]) if domain_name == "climate": return f'{domain_data["source"]}.{domain_data["setpoint"]}' if domain_name == "scene": @@ -77,13 +92,13 @@ def get_resource(domain_name, domain_data): raise ValueError("Unknown domain") -def generate_unique_id(address): +def generate_unique_id(address: AddressType) -> str: """Generate a unique_id from the given parameters.""" is_group = "g" if address[2] else "m" return f"{is_group}{address[0]:03d}{address[1]:03d}" -def import_lcn_config(lcn_config): +def import_lcn_config(lcn_config: ConfigType) -> list[ConfigType]: """Convert lcn settings from configuration.yaml to config_entries data. Create a list of config_entry data structures like: @@ -185,7 +200,7 @@ def import_lcn_config(lcn_config): return list(data.values()) -def has_unique_host_names(hosts): +def has_unique_host_names(hosts: list[ConfigType]) -> list[ConfigType]: """Validate that all connection names are unique. Use 'pchk' as default connection_name (or add a numeric suffix if @@ -206,7 +221,7 @@ def has_unique_host_names(hosts): return hosts -def is_address(value): +def is_address(value: str) -> tuple[AddressType, str]: """Validate the given address string. Examples for S000M005 at myhome: @@ -227,7 +242,7 @@ def is_address(value): raise ValueError(f"{value} is not a valid address string") -def is_states_string(states_string): +def is_states_string(states_string: str) -> list[str]: """Validate the given states string and return states list.""" if len(states_string) != 8: raise ValueError("Invalid length of states string") diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 8697d8e0319..260dd2212ea 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -1,4 +1,7 @@ """Support for LCN lights.""" +from __future__ import annotations + +from typing import Any import pypck @@ -10,7 +13,11 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, LightEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( @@ -20,15 +27,17 @@ from .const import ( CONF_TRANSITION, OUTPUT_PORTS, ) -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 -def create_lcn_light_entity(hass, entity_config, config_entry): +def create_lcn_light_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) if entity_config[CONF_DOMAIN_DATA][CONF_OUTPUT] in OUTPUT_PORTS: @@ -37,7 +46,11 @@ def create_lcn_light_entity(hass, entity_config, config_entry): return LcnRelayLight(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN light entities from a config entry.""" entities = [] @@ -51,7 +64,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnOutputLight(LcnEntity, LightEntity): """Representation of a LCN light for output ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN light.""" super().__init__(config, entry_id, device_connection) @@ -66,36 +81,36 @@ class LcnOutputLight(LcnEntity, LightEntity): self._is_on = False self._is_dimming_to_zero = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.output) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" if self.dimmable: return SUPPORT_TRANSITION | SUPPORT_BRIGHTNESS return SUPPORT_TRANSITION @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" return self._brightness @property - def is_on(self): + def is_on(self) -> bool: """Return True if entity is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if ATTR_BRIGHTNESS in kwargs: percent = int(kwargs[ATTR_BRIGHTNESS] / 255.0 * 100) @@ -116,7 +131,7 @@ class LcnOutputLight(LcnEntity, LightEntity): self._is_dimming_to_zero = False self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if ATTR_TRANSITION in kwargs: transition = pypck.lcn_defs.time_to_ramp_value( @@ -133,7 +148,7 @@ class LcnOutputLight(LcnEntity, LightEntity): self._is_on = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusOutput) @@ -144,7 +159,7 @@ class LcnOutputLight(LcnEntity, LightEntity): self._brightness = int(input_obj.get_percent() / 100.0 * 255) if self.brightness == 0: self._is_dimming_to_zero = False - if not self._is_dimming_to_zero: + if not self._is_dimming_to_zero and self.brightness is not None: self._is_on = self.brightness > 0 self.async_write_ha_state() @@ -152,7 +167,9 @@ class LcnOutputLight(LcnEntity, LightEntity): class LcnRelayLight(LcnEntity, LightEntity): """Representation of a LCN light for relay ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN light.""" super().__init__(config, entry_id, device_connection) @@ -160,24 +177,24 @@ class LcnRelayLight(LcnEntity, LightEntity): self._is_on = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.output) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) @property - def is_on(self): + def is_on(self) -> bool: """Return True if entity is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON @@ -186,7 +203,7 @@ class LcnRelayLight(LcnEntity, LightEntity): self._is_on = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF @@ -195,7 +212,7 @@ class LcnRelayLight(LcnEntity, LightEntity): self._is_on = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 8f770df7668..f1980b6475d 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -1,9 +1,16 @@ """Support for LCN scenes.""" +from __future__ import annotations + +from typing import Any import pypck from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( @@ -13,21 +20,27 @@ from .const import ( CONF_TRANSITION, OUTPUT_PORTS, ) -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, get_device_connection PARALLEL_UPDATES = 0 -def create_lcn_scene_entity(hass, entity_config, config_entry): +def create_lcn_scene_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) return LcnScene(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN switch entities from a config entry.""" entities = [] @@ -41,7 +54,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnScene(LcnEntity, Scene): """Representation of a LCN scene.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN scene.""" super().__init__(config, entry_id, device_connection) @@ -63,7 +78,7 @@ class LcnScene(LcnEntity, Scene): config[CONF_DOMAIN_DATA][CONF_TRANSITION] ) - async def async_activate(self, **kwargs): + async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" await self.device_connection.activate_scene( self.register_id, diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 64870e22e4c..fdd6ee51872 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -1,8 +1,12 @@ """Support for LCN sensors.""" +from __future__ import annotations + +from typing import cast import pypck from homeassistant.components.sensor import DOMAIN as DOMAIN_SENSOR, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ADDRESS, CONF_DOMAIN, @@ -10,6 +14,9 @@ from homeassistant.const import ( CONF_SOURCE, CONF_UNIT_OF_MEASUREMENT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import ( @@ -20,13 +27,15 @@ from .const import ( THRESHOLDS, VARIABLES, ) -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection -def create_lcn_sensor_entity(hass, entity_config, config_entry): +def create_lcn_sensor_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) if ( @@ -40,7 +49,11 @@ def create_lcn_sensor_entity(hass, entity_config, config_entry): return LcnLedLogicSensor(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN switch entities from a config entry.""" entities = [] @@ -54,7 +67,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnVariableSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for variables.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN sensor.""" super().__init__(config, entry_id, device_connection) @@ -65,29 +80,29 @@ class LcnVariableSensor(LcnEntity, SensorEntity): self._value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.variable) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.variable) @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" return self._value @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" - return self.unit.value + return cast(str, self.unit.value) - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusVar) @@ -102,7 +117,9 @@ class LcnVariableSensor(LcnEntity, SensorEntity): class LcnLedLogicSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for leds and logicops.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN sensor.""" super().__init__(config, entry_id, device_connection) @@ -115,24 +132,24 @@ class LcnLedLogicSensor(LcnEntity, SensorEntity): self._value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.source) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" return self._value - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusLedsAndLogicOps): return diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index fa74f556593..8c305d68403 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, TIME_SECONDS, ) +from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from .const import ( @@ -40,7 +41,12 @@ from .const import ( VAR_UNITS, VARIABLES, ) -from .helpers import get_device_connection, is_address, is_states_string +from .helpers import ( + DeviceConnectionType, + get_device_connection, + is_address, + is_states_string, +) class LcnServiceCall: @@ -48,11 +54,11 @@ class LcnServiceCall: schema = vol.Schema({vol.Required(CONF_ADDRESS): is_address}) - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize service call.""" self.hass = hass - def get_device_connection(self, service): + def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" address, host_name = service.data[CONF_ADDRESS] @@ -66,7 +72,7 @@ class LcnServiceCall: return device_connection raise ValueError("Invalid host name.") - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" raise NotImplementedError @@ -86,7 +92,7 @@ class OutputAbs(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" output = pypck.lcn_defs.OutputPort[service.data[CONF_OUTPUT]] brightness = service.data[CONF_BRIGHTNESS] @@ -110,7 +116,7 @@ class OutputRel(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" output = pypck.lcn_defs.OutputPort[service.data[CONF_OUTPUT]] brightness = service.data[CONF_BRIGHTNESS] @@ -131,7 +137,7 @@ class OutputToggle(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" output = pypck.lcn_defs.OutputPort[service.data[CONF_OUTPUT]] transition = pypck.lcn_defs.time_to_ramp_value( @@ -147,7 +153,7 @@ class Relays(LcnServiceCall): schema = LcnServiceCall.schema.extend({vol.Required(CONF_STATE): is_states_string}) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" states = [ pypck.lcn_defs.RelayStateModifier[state] @@ -168,7 +174,7 @@ class Led(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" led = pypck.lcn_defs.LedPort[service.data[CONF_LED]] led_state = pypck.lcn_defs.LedStatus[service.data[CONF_STATE]] @@ -196,7 +202,7 @@ class VarAbs(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" var = pypck.lcn_defs.Var[service.data[CONF_VARIABLE]] value = service.data[CONF_VALUE] @@ -213,7 +219,7 @@ class VarReset(LcnServiceCall): {vol.Required(CONF_VARIABLE): vol.All(vol.Upper, vol.In(VARIABLES + SETPOINTS))} ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" var = pypck.lcn_defs.Var[service.data[CONF_VARIABLE]] @@ -239,7 +245,7 @@ class VarRel(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" var = pypck.lcn_defs.Var[service.data[CONF_VARIABLE]] value = service.data[CONF_VALUE] @@ -260,7 +266,7 @@ class LockRegulator(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" setpoint = pypck.lcn_defs.Var[service.data[CONF_SETPOINT]] state = service.data[CONF_STATE] @@ -288,7 +294,7 @@ class SendKeys(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" device_connection = self.get_device_connection(service) @@ -331,7 +337,7 @@ class LockKeys(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" device_connection = self.get_device_connection(service) @@ -368,7 +374,7 @@ class DynText(LcnServiceCall): } ) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" row_id = service.data[CONF_ROW] - 1 text = service.data[CONF_TEXT] @@ -382,7 +388,7 @@ class Pck(LcnServiceCall): schema = LcnServiceCall.schema.extend({vol.Required(CONF_PCK): str}) - async def async_call_service(self, service): + async def async_call_service(self, service: ServiceCall) -> None: """Execute service call.""" pck = service.data[CONF_PCK] device_connection = self.get_device_connection(service) diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 1429bf67f7e..ded15c0f1da 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -1,21 +1,30 @@ """Support for LCN switches.""" +from __future__ import annotations + +from typing import Any import pypck from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_ENTITIES +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import LcnEntity from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS -from .helpers import get_device_connection +from .helpers import DeviceConnectionType, InputType, get_device_connection PARALLEL_UPDATES = 0 -def create_lcn_switch_entity(hass, entity_config, config_entry): +def create_lcn_switch_entity( + hass: HomeAssistant, entity_config: ConfigType, config_entry: ConfigEntry +) -> LcnEntity: """Set up an entity for this domain.""" device_connection = get_device_connection( - hass, tuple(entity_config[CONF_ADDRESS]), config_entry + hass, entity_config[CONF_ADDRESS], config_entry ) if entity_config[CONF_DOMAIN_DATA][CONF_OUTPUT] in OUTPUT_PORTS: @@ -24,7 +33,11 @@ def create_lcn_switch_entity(hass, entity_config, config_entry): return LcnRelaySwitch(entity_config, config_entry.entry_id, device_connection) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up LCN switch entities from a config entry.""" entities = [] @@ -39,46 +52,48 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LcnOutputSwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for output ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN switch.""" super().__init__(config, entry_id, device_connection) self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = None + self._is_on = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.output) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) @property - def is_on(self): + def is_on(self) -> bool: """Return True if entity is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.dim_output(self.output.value, 100, 0): return self._is_on = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if not await self.device_connection.dim_output(self.output.value, 0, 0): return self._is_on = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if ( not isinstance(input_obj, pypck.inputs.ModStatusOutput) @@ -93,32 +108,34 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): class LcnRelaySwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for relay ports.""" - def __init__(self, config, entry_id, device_connection): + def __init__( + self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType + ) -> None: """Initialize the LCN switch.""" super().__init__(config, entry_id, device_connection) self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = None + self._is_on = False - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if not self.device_connection.is_group: await self.device_connection.activate_status_request_handler(self.output) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) @property - def is_on(self): + def is_on(self) -> bool: """Return True if entity is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON @@ -127,7 +144,7 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): self._is_on = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF @@ -136,7 +153,7 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): self._is_on = False self.async_write_ha_state() - def input_received(self, input_obj): + def input_received(self, input_obj: InputType) -> None: """Set switch state when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 31316ca975b..5b5ce313689 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -139,7 +139,8 @@ class LgTVDevice(MediaPlayerEntity): self._sources = dict(zip(channel_names, channel_list)) # sort source names by the major channel number source_tuples = [ - (k, self._sources[k].find("major").text) for k in self._sources + (k, source.find("major").text) + for k, source in self._sources.items() ] sorted_sources = sorted( source_tuples, key=lambda channel: int(channel[1]) diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 5403a483ffb..6697dd50893 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -189,7 +189,7 @@ class Life360Scanner: { ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}", ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split( - "." + ".", maxsplit=1 )[0], }, ) diff --git a/homeassistant/components/life360/translations/de.json b/homeassistant/components/life360/translations/de.json index 7e495987b45..516b0255349 100644 --- a/homeassistant/components/life360/translations/de.json +++ b/homeassistant/components/life360/translations/de.json @@ -8,7 +8,7 @@ "default": "M\u00f6gliche erweiterte Einstellungen finden sich unter [Life360-Dokumentation]({docs_url})." }, "error": { - "already_configured": "Konto ist bereits konfiguriert", + "already_configured": "Konto wurde bereits konfiguriert", "invalid_auth": "Ung\u00fcltige Authentifizierung", "invalid_username": "Ung\u00fcltiger Benutzername", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 167a1ceee0a..30c0ffbe850 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -275,12 +275,12 @@ class LIFXManager: for discovery in self.discoveries: discovery.cleanup() - for service in [ + for service in ( SERVICE_LIFX_SET_STATE, SERVICE_EFFECT_STOP, SERVICE_EFFECT_PULSE, SERVICE_EFFECT_COLORLOOP, - ]: + ): self.hass.services.async_remove(LIFX_DOMAIN, service) def register_set_state(self): diff --git a/homeassistant/components/lifx/translations/de.json b/homeassistant/components/lifx/translations/de.json index 83eded1ddc6..0c619ea4062 100644 --- a/homeassistant/components/lifx/translations/de.json +++ b/homeassistant/components/lifx/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Keine LIFX Ger\u00e4te im Netzwerk gefunden", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index e92999f4d21..51f387a2cdb 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import bind_hass import homeassistant.util.color as color_util @@ -586,7 +586,7 @@ class Profiles: for profile_path in profile_paths: if not os.path.isfile(profile_path): continue - with open(profile_path) as inp: + with open(profile_path, encoding="utf8") as inp: reader = csv.reader(inp) # Skip the header @@ -638,9 +638,15 @@ class Profiles: params.setdefault(ATTR_TRANSITION, profile.transition) +@dataclasses.dataclass +class LightEntityDescription(ToggleEntityDescription): + """A class that describes binary sensor entities.""" + + class LightEntity(ToggleEntity): """Base class for light entities.""" + entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: str | None = None _attr_color_temp: int | None = None diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 778203a1c93..3b7df4e70c5 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -296,11 +296,7 @@ turn_on: name: Effect description: Light effect. selector: - select: - options: - - colorloop - - random - - white + text: turn_off: name: Turn off diff --git a/homeassistant/components/light/translations/ar.json b/homeassistant/components/light/translations/ar.json index 23554bd603b..3b54e253bec 100644 --- a/homeassistant/components/light/translations/ar.json +++ b/homeassistant/components/light/translations/ar.json @@ -1,4 +1,17 @@ { + "device_automation": { + "action_type": { + "flash": "\u0641\u0644\u0627\u0634 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0642\u064a\u062f \u0627\u0644\u0627\u064a\u0642\u0627\u0641", + "is_on": "{entity_name} \u0642\u064a\u062f \u0627\u0644\u062a\u0634\u063a\u064a\u0644" + }, + "trigger_type": { + "turned_off": "{entity_name} \u062a\u0645 \u0625\u064a\u0642\u0627\u0641 \u062a\u0634\u063a\u064a\u0644\u0647", + "turned_on": "{entity_name} \u062a\u0645 \u062a\u0634\u063a\u064a\u0644\u0647" + } + }, "state": { "_": { "off": "\u0625\u064a\u0642\u0627\u0641", diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index 2e63c150e41..4f8128bd6dc 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -10,13 +10,44 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PORT +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import DOMAIN +from .const import CONF_DEFAULT_TRANSITION, DOMAIN _LOGGER = logging.getLogger(__name__) +class LiteJetOptionsFlow(config_entries.OptionsFlow): + """Handle LiteJet options.""" + + def __init__(self, config_entry): + """Initialize LiteJet options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Manage LiteJet 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.Optional( + CONF_DEFAULT_TRANSITION, + default=self.config_entry.options.get( + CONF_DEFAULT_TRANSITION, 0 + ), + ): cv.positive_int, + } + ), + ) + + class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """LiteJet config flow.""" @@ -54,3 +85,9 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data): """Import litejet config from configuration.yaml.""" return self.async_create_entry(title=import_data[CONF_PORT], data=import_data) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return LiteJetOptionsFlow(config_entry) diff --git a/homeassistant/components/litejet/const.py b/homeassistant/components/litejet/const.py index 8e27aa3a0a7..82521092106 100644 --- a/homeassistant/components/litejet/const.py +++ b/homeassistant/components/litejet/const.py @@ -6,3 +6,5 @@ CONF_EXCLUDE_NAMES = "exclude_names" CONF_INCLUDE_SWITCHES = "include_switches" PLATFORMS = ["light", "switch", "scene"] + +CONF_DEFAULT_TRANSITION = "default_transition" diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py index 5248afb4dbd..172e46c441a 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -3,11 +3,13 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_TRANSITION, SUPPORT_BRIGHTNESS, + SUPPORT_TRANSITION, LightEntity, ) -from .const import DOMAIN +from .const import CONF_DEFAULT_TRANSITION, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -23,7 +25,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for i in system.loads(): name = system.get_load_name(i) - entities.append(LiteJetLight(config_entry.entry_id, system, i, name)) + entities.append(LiteJetLight(config_entry, system, i, name)) return entities async_add_entities(await hass.async_add_executor_job(get_entities, system), True) @@ -32,9 +34,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class LiteJetLight(LightEntity): """Representation of a single LiteJet light.""" - def __init__(self, entry_id, lj, i, name): + def __init__(self, config_entry, lj, i, name): """Initialize a LiteJet light.""" - self._entry_id = entry_id + self._config_entry = config_entry self._lj = lj self._index = i self._brightness = 0 @@ -57,7 +59,7 @@ class LiteJetLight(LightEntity): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS + return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION @property def name(self): @@ -67,7 +69,7 @@ class LiteJetLight(LightEntity): @property def unique_id(self): """Return a unique identifier for this light.""" - return f"{self._entry_id}_{self._index}" + return f"{self._config_entry.entry_id}_{self._index}" @property def brightness(self): @@ -91,16 +93,33 @@ class LiteJetLight(LightEntity): def turn_on(self, **kwargs): """Turn on the light.""" - if ATTR_BRIGHTNESS in kwargs: - brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 99) - self._lj.activate_load_at(self._index, brightness, 0) - else: + + # If neither attribute is specified then the simple activate load + # LiteJet API will use the per-light default brightness and + # transition values programmed in the LiteJet system. + if ATTR_BRIGHTNESS not in kwargs and ATTR_TRANSITION not in kwargs: self._lj.activate_load(self._index) + return + + # If either attribute is specified then Home Assistant must + # control both values. + default_transition = self._config_entry.options.get(CONF_DEFAULT_TRANSITION, 0) + transition = kwargs.get(ATTR_TRANSITION, default_transition) + brightness = int(kwargs.get(ATTR_BRIGHTNESS, 255) / 255 * 99) + + self._lj.activate_load_at(self._index, brightness, int(transition)) def turn_off(self, **kwargs): """Turn off the light.""" + if ATTR_TRANSITION in kwargs: + self._lj.activate_load_at(self._index, 0, kwargs[ATTR_TRANSITION]) + return + + # If transition attribute is not specified then the simple + # deactivate load LiteJet API will use the per-light default + # transition value programmed in the LiteJet system. self._lj.deactivate_load(self._index) def update(self): """Retrieve the light's brightness from the LiteJet system.""" - self._brightness = self._lj.get_load_level(self._index) / 99 * 255 + self._brightness = int(self._lj.get_load_level(self._index) / 99 * 255) diff --git a/homeassistant/components/litejet/strings.json b/homeassistant/components/litejet/strings.json index 79c4ed5f329..426dcd374af 100644 --- a/homeassistant/components/litejet/strings.json +++ b/homeassistant/components/litejet/strings.json @@ -15,5 +15,15 @@ "error": { "open_failed": "Cannot open the specified serial port." } + }, + "options": { + "step": { + "init": { + "title": "Configure LiteJet", + "data": { + "default_transition": "Default Transition (seconds)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/ca.json b/homeassistant/components/litejet/translations/ca.json index 39e2a56dc4d..2b301a256db 100644 --- a/homeassistant/components/litejet/translations/ca.json +++ b/homeassistant/components/litejet/translations/ca.json @@ -15,5 +15,15 @@ "title": "Connexi\u00f3 amb LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transici\u00f3 predeterminada (segons)" + }, + "title": "Configuraci\u00f3 de LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/de.json b/homeassistant/components/litejet/translations/de.json index ff528dd79b2..e3788821e58 100644 --- a/homeassistant/components/litejet/translations/de.json +++ b/homeassistant/components/litejet/translations/de.json @@ -15,5 +15,15 @@ "title": "Verbinde zu LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Standard\u00fcbergang (Sekunden)" + }, + "title": "LiteJet konfigurieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/en.json b/homeassistant/components/litejet/translations/en.json index e09b20dc9f2..146aad276c4 100644 --- a/homeassistant/components/litejet/translations/en.json +++ b/homeassistant/components/litejet/translations/en.json @@ -15,5 +15,15 @@ "title": "Connect To LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Default Transition (seconds)" + }, + "title": "Configure LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/et.json b/homeassistant/components/litejet/translations/et.json index 6e50b5dcdf3..ef6666ab025 100644 --- a/homeassistant/components/litejet/translations/et.json +++ b/homeassistant/components/litejet/translations/et.json @@ -15,5 +15,15 @@ "title": "Loo \u00fchendus LiteJetiga" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Heleduse vaike\u00fclemineku aeg (sekundites)" + }, + "title": "LiteJeti seadistamine" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/fr.json b/homeassistant/components/litejet/translations/fr.json index 89459d1829f..b8ca0adb3d4 100644 --- a/homeassistant/components/litejet/translations/fr.json +++ b/homeassistant/components/litejet/translations/fr.json @@ -15,5 +15,15 @@ "title": "Connectez-vous \u00e0 LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transition par d\u00e9faut (secondes)" + }, + "title": "Configurer LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/hu.json b/homeassistant/components/litejet/translations/hu.json index 6d895624c30..3ee53c086bf 100644 --- a/homeassistant/components/litejet/translations/hu.json +++ b/homeassistant/components/litejet/translations/hu.json @@ -3,11 +3,15 @@ "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, + "error": { + "open_failed": "A megadott soros port nem nyithat\u00f3 meg." + }, "step": { "user": { "data": { "port": "Port" }, + "description": "Csatlakoztassa a LiteJet RS232-2 portj\u00e1t a sz\u00e1m\u00edt\u00f3g\u00e9p\u00e9hez, \u00e9s adja meg a soros port eszk\u00f6z\u00e9nek el\u00e9r\u00e9si \u00fatj\u00e1t. \n\n A LiteJet MCP-t 19,2 K baudra, 8 adatbitre, 1 stopbitre, parit\u00e1s n\u00e9lk\u00fcl kell konfigur\u00e1lni, \u00e9s minden v\u00e1lasz ut\u00e1n \u201eCR\u201d jelet kell tov\u00e1bb\u00edtania.", "title": "Csatlakoz\u00e1s a LiteJet-hez" } } diff --git a/homeassistant/components/litejet/translations/it.json b/homeassistant/components/litejet/translations/it.json index 5b3dc46753d..0b34e6fb365 100644 --- a/homeassistant/components/litejet/translations/it.json +++ b/homeassistant/components/litejet/translations/it.json @@ -15,5 +15,15 @@ "title": "Connetti a LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Transizione predefinita (secondi)" + }, + "title": "Configura LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/nl.json b/homeassistant/components/litejet/translations/nl.json index a96f8de6171..e2743b4165f 100644 --- a/homeassistant/components/litejet/translations/nl.json +++ b/homeassistant/components/litejet/translations/nl.json @@ -15,5 +15,15 @@ "title": "Maak verbinding met LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Standaard overgang (seconden)" + }, + "title": "Configureer LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/pl.json b/homeassistant/components/litejet/translations/pl.json index 20e5d68288d..ae26b5e7283 100644 --- a/homeassistant/components/litejet/translations/pl.json +++ b/homeassistant/components/litejet/translations/pl.json @@ -15,5 +15,15 @@ "title": "Po\u0142\u0105czenie z LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "Domy\u015blny czas efektu przej\u015bcia (w sekundach)" + }, + "title": "Konfiguracja LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/ru.json b/homeassistant/components/litejet/translations/ru.json index c90e6956301..b6ad0e913e9 100644 --- a/homeassistant/components/litejet/translations/ru.json +++ b/homeassistant/components/litejet/translations/ru.json @@ -15,5 +15,15 @@ "title": "\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "\u041f\u0435\u0440\u0435\u0445\u043e\u0434 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)" + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litejet/translations/zh-Hant.json b/homeassistant/components/litejet/translations/zh-Hant.json index 8a268f3db49..3e6886e74a3 100644 --- a/homeassistant/components/litejet/translations/zh-Hant.json +++ b/homeassistant/components/litejet/translations/zh-Hant.json @@ -15,5 +15,15 @@ "title": "\u9023\u7dda\u81f3 LiteJet" } } + }, + "options": { + "step": { + "init": { + "data": { + "default_transition": "\u9810\u8a2d\u8f49\u63db\u6642\u9593\uff08\u79d2\uff09" + }, + "title": "\u8a2d\u5b9a LiteJet" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 346bb5e0761..a22499fb062 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -3,7 +3,7 @@ "name": "Litter-Robot", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/litterrobot", - "requirements": ["pylitterbot==2021.3.1"], + "requirements": ["pylitterbot==2021.7.2"], "codeowners": ["@natekspencer"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/litterrobot/translations/de.json b/homeassistant/components/litterrobot/translations/de.json index c8f4f35716e..14f319fb4d3 100644 --- a/homeassistant/components/litterrobot/translations/de.json +++ b/homeassistant/components/litterrobot/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", diff --git a/homeassistant/components/local_ip/manifest.json b/homeassistant/components/local_ip/manifest.json index f7e245aac05..cec6e094f50 100644 --- a/homeassistant/components/local_ip/manifest.json +++ b/homeassistant/components/local_ip/manifest.json @@ -3,6 +3,7 @@ "name": "Local IP Address", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/local_ip", + "dependencies": ["network"], "codeowners": ["@issacg"], "iot_class": "local_polling" } diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py index c7bc53caa69..661ef88e641 100644 --- a/homeassistant/components/local_ip/sensor.py +++ b/homeassistant/components/local_ip/sensor.py @@ -1,11 +1,12 @@ """Sensor platform for local_ip.""" +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import get_local_ip from .const import DOMAIN, SENSOR @@ -30,6 +31,8 @@ class IPSensor(SensorEntity): """Initialize the sensor.""" self._attr_name = name - def update(self) -> None: + async def async_update(self) -> None: """Fetch new state data for the sensor.""" - self._attr_state = get_local_ip() + self._attr_state = await async_get_source_ip( + self.hass, target_ip=PUBLIC_TARGET_IP + ) diff --git a/homeassistant/components/local_ip/translations/de.json b/homeassistant/components/local_ip/translations/de.json index 345874615ec..07aa81c0c3e 100644 --- a/homeassistant/components/local_ip/translations/de.json +++ b/homeassistant/components/local_ip/translations/de.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", "title": "Lokale IP-Adresse" } } diff --git a/homeassistant/components/locative/translations/de.json b/homeassistant/components/locative/translations/de.json index 2df9f889e85..5ca00363476 100644 --- a/homeassistant/components/locative/translations/de.json +++ b/homeassistant/components/locative/translations/de.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", "title": "Locative Webhook einrichten" } } diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 9e8bf3a740c..860778d61f3 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -1,6 +1,7 @@ """Component to interface with locks that can be controlled remotely.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -15,8 +16,11 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -25,7 +29,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType @@ -81,12 +85,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class LockEntityDescription(EntityDescription): + """A class that describes lock entities.""" + + class LockEntity(Entity): """Base class for lock entities.""" + entity_description: LockEntityDescription _attr_changed_by: str | None = None _attr_code_format: str | None = None _attr_is_locked: bool | None = None + _attr_is_locking: bool | None = None + _attr_is_unlocking: bool | None = None + _attr_is_jammed: bool | None = None _attr_state: None = None @property @@ -104,6 +117,21 @@ class LockEntity(Entity): """Return true if the lock is locked.""" return self._attr_is_locked + @property + def is_locking(self) -> bool | None: + """Return true if the lock is locking.""" + return self._attr_is_locking + + @property + def is_unlocking(self) -> bool | None: + """Return true if the lock is unlocking.""" + return self._attr_is_unlocking + + @property + def is_jammed(self) -> bool | None: + """Return true if the lock is jammed (incomplete locking).""" + return self._attr_is_jammed + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" raise NotImplementedError() @@ -143,6 +171,12 @@ class LockEntity(Entity): @property def state(self) -> str | None: """Return the state.""" + if self.is_jammed: + return STATE_JAMMED + if self.is_locking: + return STATE_LOCKING + if self.is_unlocking: + return STATE_UNLOCKING locked = self.is_locked if locked is None: return None diff --git a/homeassistant/components/lock/device_condition.py b/homeassistant/components/lock/device_condition.py index 3e77a23ffdb..d0829eb742b 100644 --- a/homeassistant/components/lock/device_condition.py +++ b/homeassistant/components/lock/device_condition.py @@ -10,8 +10,11 @@ from homeassistant.const import ( CONF_DOMAIN, CONF_ENTITY_ID, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -20,7 +23,13 @@ from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -CONDITION_TYPES = {"is_locked", "is_unlocked"} +CONDITION_TYPES = { + "is_locked", + "is_unlocked", + "is_locking", + "is_unlocking", + "is_jammed", +} CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( { @@ -60,7 +69,13 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config_validation: config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_locked": + if config[CONF_TYPE] == "is_jammed": + state = STATE_JAMMED + elif config[CONF_TYPE] == "is_locking": + state = STATE_LOCKING + elif config[CONF_TYPE] == "is_unlocking": + state = STATE_UNLOCKING + elif config[CONF_TYPE] == "is_locked": state = STATE_LOCKED else: state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/device_trigger.py b/homeassistant/components/lock/device_trigger.py index 2e96b470893..641030e9f23 100644 --- a/homeassistant/components/lock/device_trigger.py +++ b/homeassistant/components/lock/device_trigger.py @@ -13,8 +13,11 @@ from homeassistant.const import ( CONF_FOR, CONF_PLATFORM, CONF_TYPE, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry @@ -22,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN -TRIGGER_TYPES = {"locked", "unlocked"} +TRIGGER_TYPES = {"locked", "unlocked", "locking", "unlocking", "jammed"} TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { @@ -74,7 +77,13 @@ async def async_attach_trigger( automation_info: dict, ) -> CALLBACK_TYPE: """Attach a trigger.""" - if config[CONF_TYPE] == "locked": + if config[CONF_TYPE] == "jammed": + to_state = STATE_JAMMED + elif config[CONF_TYPE] == "locking": + to_state = STATE_LOCKING + elif config[CONF_TYPE] == "unlocking": + to_state = STATE_UNLOCKING + elif config[CONF_TYPE] == "locked": to_state = STATE_LOCKED else: to_state = STATE_UNLOCKED diff --git a/homeassistant/components/lock/reproduce_state.py b/homeassistant/components/lock/reproduce_state.py index ea5cf370af6..cdd538c88be 100644 --- a/homeassistant/components/lock/reproduce_state.py +++ b/homeassistant/components/lock/reproduce_state.py @@ -11,7 +11,9 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.core import Context, HomeAssistant, State @@ -19,7 +21,7 @@ from . import DOMAIN _LOGGER = logging.getLogger(__name__) -VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED} +VALID_STATES = {STATE_LOCKED, STATE_UNLOCKED, STATE_LOCKING, STATE_UNLOCKING} async def _async_reproduce_state( @@ -48,9 +50,9 @@ async def _async_reproduce_state( service_data = {ATTR_ENTITY_ID: state.entity_id} - if state.state == STATE_LOCKED: + if state.state in {STATE_LOCKED, STATE_LOCKING}: service = SERVICE_LOCK - elif state.state == STATE_UNLOCKED: + elif state.state in {STATE_UNLOCKED, STATE_UNLOCKING}: service = SERVICE_UNLOCK await hass.services.async_call( diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 87b66c57730..8992ca2d7fc 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -48,11 +48,10 @@ from homeassistant.helpers.integration_platform import ( from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util -ENTITY_ID_JSON_TEMPLATE = '"entity_id": "{}"' -ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": "([^"]+)"') -DOMAIN_JSON_EXTRACT = re.compile('"domain": "([^"]+)"') -ICON_JSON_EXTRACT = re.compile('"icon": "([^"]+)"') - +ENTITY_ID_JSON_TEMPLATE = '"entity_id": ?"{}"' +ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"') +DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') +ICON_JSON_EXTRACT = re.compile('"icon": ?"([^"]+)"') ATTR_MESSAGE = "message" CONTINUOUS_DOMAINS = ["proximity", "sensor"] @@ -574,10 +573,10 @@ def _apply_event_types_filter(hass, query, event_types): def _apply_event_entity_id_matchers(events_query, entity_ids): return events_query.filter( sqlalchemy.or_( - *[ + *( Events.event_data.contains(ENTITY_ID_JSON_TEMPLATE.format(entity_id)) for entity_id in entity_ids - ] + ) ) ) diff --git a/homeassistant/components/lovelace/system_health.py b/homeassistant/components/lovelace/system_health.py index 2f4cfc6af76..29b53251f21 100644 --- a/homeassistant/components/lovelace/system_health.py +++ b/homeassistant/components/lovelace/system_health.py @@ -22,10 +22,10 @@ async def system_health_info(hass): health_info.update(await hass.data[DOMAIN]["resources"].async_get_info()) dashboards_info = await asyncio.gather( - *[ + *( hass.data[DOMAIN]["dashboards"][dashboard].async_get_info() for dashboard in hass.data[DOMAIN]["dashboards"] - ] + ) ) modes = set() diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index f03448fa3a9..5dffab65d75 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -12,6 +12,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SHOW_ON_MAP, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS, @@ -45,19 +48,41 @@ SENSOR_TEMPERATURE = "temperature" TOPIC_UPDATE = f"{DOMAIN}_data_update" SENSORS = { - SENSOR_TEMPERATURE: ["Temperature", "mdi:thermometer", TEMP_CELSIUS], - SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", PERCENTAGE], - SENSOR_PRESSURE: ["Pressure", "mdi:arrow-down-bold", PRESSURE_HPA], - SENSOR_PRESSURE_AT_SEALEVEL: ["Pressure at sealevel", "mdi:download", PRESSURE_HPA], + SENSOR_TEMPERATURE: [ + "Temperature", + "mdi:thermometer", + TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, + ], + SENSOR_HUMIDITY: [ + "Humidity", + "mdi:water-percent", + PERCENTAGE, + DEVICE_CLASS_HUMIDITY, + ], + SENSOR_PRESSURE: [ + "Pressure", + "mdi:arrow-down-bold", + PRESSURE_HPA, + DEVICE_CLASS_PRESSURE, + ], + SENSOR_PRESSURE_AT_SEALEVEL: [ + "Pressure at sealevel", + "mdi:download", + PRESSURE_HPA, + DEVICE_CLASS_PRESSURE, + ], SENSOR_PM10: [ "PM10", "mdi:thought-bubble", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + None, ], SENSOR_PM2_5: [ "PM2.5", "mdi:thought-bubble-outline", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + None, ], } diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py index 0df12fc8907..f13fcc831dc 100644 --- a/homeassistant/components/luftdaten/config_flow.py +++ b/homeassistant/components/luftdaten/config_flow.py @@ -81,7 +81,7 @@ class LuftDatenFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self._show_form({CONF_SENSOR_ID: "invalid_sensor"}) available_sensors = [ - x for x in luftdaten.values if luftdaten.values[x] is not None + x for x, x_values in luftdaten.values.items() if x_values is not None ] if available_sensors: diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index aec77961b94..b27cc35ab26 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -31,14 +31,20 @@ async def async_setup_entry(hass, entry, async_add_entities): sensors = [] for sensor_type in luftdaten.sensor_conditions: try: - name, icon, unit = SENSORS[sensor_type] + name, icon, unit, device_class = SENSORS[sensor_type] except KeyError: _LOGGER.debug("Unknown sensor value type: %s", sensor_type) continue sensors.append( LuftdatenSensor( - luftdaten, sensor_type, name, icon, unit, entry.data[CONF_SHOW_ON_MAP] + luftdaten, + sensor_type, + name, + icon, + unit, + device_class, + entry.data[CONF_SHOW_ON_MAP], ) ) @@ -48,7 +54,7 @@ async def async_setup_entry(hass, entry, async_add_entities): class LuftdatenSensor(SensorEntity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, sensor_type, name, icon, unit, show): + def __init__(self, luftdaten, sensor_type, name, icon, unit, device_class, show): """Initialize the Luftdaten sensor.""" self._async_unsub_dispatcher_connect = None self.luftdaten = luftdaten @@ -59,6 +65,7 @@ class LuftdatenSensor(SensorEntity): self._unit_of_measurement = unit self._show_on_map = show self._attrs = {} + self._attr_device_class = device_class @property def icon(self): diff --git a/homeassistant/components/luftdaten/translations/he.json b/homeassistant/components/luftdaten/translations/he.json index 11a4c93b42a..fba5b8e0492 100644 --- a/homeassistant/components/luftdaten/translations/he.json +++ b/homeassistant/components/luftdaten/translations/he.json @@ -3,6 +3,13 @@ "error": { "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u05d4\u05e6\u05d2 \u05d1\u05de\u05e4\u05d4" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index ec7010295ec..9d028e97c87 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -137,7 +137,9 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def _write_tls_assets(self, assets): """Write the tls assets to disk.""" for asset_key, conf_key in FILE_MAPPING.items(): - with open(self.hass.config.path(self.data[conf_key]), "w") as file_handle: + with open( + self.hass.config.path(self.data[conf_key]), "w", encoding="utf8" + ) as file_handle: file_handle.write(assets[asset_key]) def _tls_assets_exist(self): diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 36de8cab15b..b6f0785ffe7 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -2,7 +2,7 @@ "domain": "lutron_caseta", "name": "Lutron Cas\u00e9ta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", - "requirements": ["pylutron-caseta==0.10.0", "aiolip==1.1.4"], + "requirements": ["pylutron-caseta==0.11.0", "aiolip==1.1.6"], "config_flow": true, "zeroconf": ["_leap._tcp.local."], "homekit": { diff --git a/homeassistant/components/lutron_caseta/translations/de.json b/homeassistant/components/lutron_caseta/translations/de.json index e87d4cc0bdb..4bd8e2a5931 100644 --- a/homeassistant/components/lutron_caseta/translations/de.json +++ b/homeassistant/components/lutron_caseta/translations/de.json @@ -15,7 +15,7 @@ "title": "Import der Cas\u00e9ta-Bridge-Konfiguration fehlgeschlagen." }, "link": { - "description": "Um ein Pairing mit {name} ({host}) durchzuf\u00fchren, dr\u00fccken Sie nach dem Absenden dieses Formulars die schwarze Taste auf der R\u00fcckseite der Br\u00fccke.", + "description": "Um ein Pairing mit {name} ({host}) durchzuf\u00fchren, dr\u00fccke nach dem Absenden dieses Formulars die schwarze Taste auf der R\u00fcckseite der Br\u00fccke.", "title": "Mit der Bridge verbinden" }, "user": { diff --git a/homeassistant/components/lutron_caseta/translations/hu.json b/homeassistant/components/lutron_caseta/translations/hu.json index a8e62b37390..905fc05bf8e 100644 --- a/homeassistant/components/lutron_caseta/translations/hu.json +++ b/homeassistant/components/lutron_caseta/translations/hu.json @@ -2,18 +2,24 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "not_lutron_device": "A felfedezett eszk\u00f6z nem Lutron eszk\u00f6z" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "flow_title": "{name} ({host})", "step": { + "link": { + "description": "A(z) {name} {host} p\u00e1ros\u00edt\u00e1s\u00e1hoz az \u0171rlap elk\u00fcld\u00e9se ut\u00e1n nyomja meg a h\u00edd h\u00e1tulj\u00e1n tal\u00e1lhat\u00f3 fekete gombot.", + "title": "P\u00e1ros\u00edtsd a h\u00edddal" + }, "user": { "data": { "host": "Hoszt" }, - "description": "Add meg az eszk\u00f6z IP-c\u00edm\u00e9t." + "description": "Add meg az eszk\u00f6z IP-c\u00edm\u00e9t.", + "title": "Automatikus csatlakoz\u00e1s a h\u00eddhoz" } } }, @@ -23,9 +29,44 @@ "button_2": "M\u00e1sodik gomb", "button_3": "Harmadik gomb", "button_4": "Negyedik gomb", + "close_1": "Bez\u00e1r\u00e1s 1.", + "close_2": "Bez\u00e1r\u00e1s 2.", + "close_3": "Bez\u00e1r\u00e1s 3.", + "close_4": "Bez\u00e1r\u00e1s 4.", + "close_all": "Z\u00e1rja be az \u00f6sszeset", + "group_1_button_1": "Els\u0151 csoport els\u0151 gomb", + "group_1_button_2": "Els\u0151 csoport m\u00e1sodik gomb", + "group_2_button_1": "M\u00e1sodik csoport els\u0151 gomb", + "group_2_button_2": "M\u00e1sodik csoport m\u00e1sodik gomb", + "lower": "Als\u00f3", + "lower_1": "Als\u00f3 1", + "lower_2": "Als\u00f3 2", + "lower_3": "Als\u00f3 3", + "lower_4": "Als\u00f3 4", + "lower_all": "Engedje le az \u00f6sszeset", "off": "Ki", "on": "Be", + "open_1": "Nyissa meg az 1-t", + "open_2": "Nyissa meg a 2-at", + "open_3": "Nyissa meg a 3-at", + "open_4": "Nyissa meg a 4-et", + "open_all": "Nyissa meg az \u00f6sszeset", + "raise": "Emel", + "raise_1": "1. emel\u00e9s", + "raise_2": "2. emel\u00e9s", + "raise_3": "3. emel\u00e9s", + "raise_4": "4. emel\u00e9s", + "raise_all": "Emelje fel mindet", + "stop": "Meg\u00e1ll\u00f3 (kedvenc)", + "stop_1": "1. meg\u00e1ll\u00f3", + "stop_2": "2. meg\u00e1ll\u00f3", + "stop_3": "3. meg\u00e1ll\u00f3", + "stop_4": "4. meg\u00e1ll\u00f3", "stop_all": "Az \u00f6sszes le\u00e1ll\u00edt\u00e1sa" + }, + "trigger_type": { + "press": "\"{subtype}\" lenyomva", + "release": "\"{subtype}\" felengedve" } } } \ No newline at end of file diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index c979231a216..39cfff38a1b 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -39,6 +39,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Lyft sensor.""" + _LOGGER.warning( + "The Lyft integration has been deprecated and will be removed in " + "Home Assistant Core 2021.10" + ) auth_flow = ClientCredentialGrant( client_id=config.get(CONF_CLIENT_ID), diff --git a/homeassistant/components/lyric/translations/fr.json b/homeassistant/components/lyric/translations/fr.json index db23120b40d..9eb02fc3811 100644 --- a/homeassistant/components/lyric/translations/fr.json +++ b/homeassistant/components/lyric/translations/fr.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.", - "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation." + "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "create_entry": { "default": "Authentification r\u00e9ussie" @@ -12,7 +13,8 @@ "title": "S\u00e9lectionner une m\u00e9thode d'authentification" }, "reauth_confirm": { - "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte." + "description": "L'int\u00e9gration Lyric doit authentifier \u00e0 nouveau votre compte.", + "title": "R\u00e9authentification de l'int\u00e9gration" } } } diff --git a/homeassistant/components/lyric/translations/hu.json b/homeassistant/components/lyric/translations/hu.json index cae1f6d20c0..c6174673a90 100644 --- a/homeassistant/components/lyric/translations/hu.json +++ b/homeassistant/components/lyric/translations/hu.json @@ -2,7 +2,8 @@ "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t." + "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "create_entry": { "default": "Sikeres hiteles\u00edt\u00e9s" @@ -10,6 +11,10 @@ "step": { "pick_implementation": { "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" + }, + "reauth_confirm": { + "description": "A Lyric integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a fi\u00f3kj\u00e1t.", + "title": "Az integr\u00e1ci\u00f3 \u00fajb\u00f3li azonos\u00edt\u00e1sa" } } } diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 5d05596fb23..307bd54195f 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -254,8 +254,7 @@ class MailboxMediaView(MailboxView): try: stream = await mailbox.async_get_media(msgid) except StreamError as err: - error_msg = "Error getting media: %s" % (err) - _LOGGER.error(error_msg) + _LOGGER.error("Error getting media: %s", err) return web.Response(status=HTTP_INTERNAL_SERVER_ERROR) if stream: return web.Response(body=stream, content_type=mailbox.media_type) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index c28d20196e9..4e31b99c172 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -2,7 +2,7 @@ "domain": "matrix", "name": "Matrix", "documentation": "https://www.home-assistant.io/integrations/matrix", - "requirements": ["matrix-client==0.3.2"], + "requirements": ["matrix-client==0.4.0"], "codeowners": ["@tinloaf"], "iot_class": "cloud_push" } diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 921dd4c06c5..c64a3b35993 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -28,7 +28,6 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, UpdateFailed, ) -from homeassistant.util.async_ import gather_with_concurrency from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICES @@ -143,14 +142,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: vehicles = await with_timeout(mazda_client.get_vehicles()) - vehicle_status_tasks = [ - with_timeout(mazda_client.get_vehicle_status(vehicle["id"])) - for vehicle in vehicles - ] - statuses = await gather_with_concurrency(5, *vehicle_status_tasks) - - for vehicle, status in zip(vehicles, statuses): - vehicle["status"] = status + # The Mazda API can throw an error when multiple simultaneous requests are + # made for the same account, so we can only make one request at a time here + for vehicle in vehicles: + vehicle["status"] = await with_timeout( + mazda_client.get_vehicle_status(vehicle["id"]) + ) hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json index a7bed8725af..d2cc1bcfec9 100644 --- a/homeassistant/components/mazda/strings.json +++ b/homeassistant/components/mazda/strings.json @@ -1,26 +1,24 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "account_locked": "Account locked. Please try again later.", - "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%]", - "region": "Region" - }, - "description": "Please enter the email address and password you use to log into the MyMazda mobile app.", - "title": "Mazda Connected Services - Add Account" - } - } + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, - "title": "Mazda Connected Services" -} \ No newline at end of file + "error": { + "account_locked": "Account locked. Please try again later.", + "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%]", + "region": "Region" + }, + "description": "Please enter the email address and password you use to log into the MyMazda mobile app." + } + } + } +} diff --git a/homeassistant/components/mazda/translations/de.json b/homeassistant/components/mazda/translations/de.json index 01dc3d97ffa..58409ec9b8a 100644 --- a/homeassistant/components/mazda/translations/de.json +++ b/homeassistant/components/mazda/translations/de.json @@ -5,7 +5,7 @@ "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { - "account_locked": "Konto gesperrt. Bitte versuchen Sie es sp\u00e4ter erneut.", + "account_locked": "Konto gesperrt. Bitte versuche es sp\u00e4ter erneut.", "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" @@ -17,7 +17,7 @@ "password": "Passwort", "region": "Region" }, - "description": "Bitte geben Sie die E-Mail-Adresse und das Passwort ein, die Sie f\u00fcr die Anmeldung bei der MyMazda Mobile App verwenden.", + "description": "Bitte gib die E-Mail-Adresse und das Passwort ein, die du f\u00fcr die Anmeldung bei der MyMazda Mobile App verwendest.", "title": "Mazda Connected Services - Konto hinzuf\u00fcgen" } } diff --git a/homeassistant/components/mazda/translations/hu.json b/homeassistant/components/mazda/translations/hu.json index f881bfbac45..e6b80240184 100644 --- a/homeassistant/components/mazda/translations/hu.json +++ b/homeassistant/components/mazda/translations/hu.json @@ -17,6 +17,7 @@ "password": "Jelsz\u00f3", "region": "R\u00e9gi\u00f3" }, + "description": "K\u00e9rj\u00fck, adja meg azt az e-mail c\u00edmet \u00e9s jelsz\u00f3t, amelyet a MyMazda mobilalkalmaz\u00e1sba val\u00f3 bejelentkez\u00e9shez haszn\u00e1lt.", "title": "Mazda Connected Services - Fi\u00f3k hozz\u00e1ad\u00e1sa" } } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ffdabe6fed7..6978b90c897 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -5,6 +5,7 @@ import asyncio import base64 import collections from contextlib import suppress +from dataclasses import dataclass import datetime as dt import functools as ft import hashlib @@ -61,7 +62,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, datetime, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.network import get_url from homeassistant.loader import bind_hass @@ -371,9 +372,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class MediaPlayerEntityDescription(EntityDescription): + """A class that describes media player entities.""" + + class MediaPlayerEntity(Entity): """ABC for media player entities.""" + entity_description: MediaPlayerEntityDescription _access_token: str | None = None _attr_app_id: str | None = None diff --git a/homeassistant/components/media_player/translations/ar.json b/homeassistant/components/media_player/translations/ar.json index 5fa1f1d6df5..c1b45e44893 100644 --- a/homeassistant/components/media_player/translations/ar.json +++ b/homeassistant/components/media_player/translations/ar.json @@ -1,4 +1,12 @@ { + "device_automation": { + "condition_type": { + "is_idle": "{entity_name} \u062e\u0627\u0645\u0644" + }, + "trigger_type": { + "idle": "{entity_name} \u0627\u0635\u0628\u062d \u062e\u0627\u0645\u0644\u0627" + } + }, "state": { "_": { "idle": "\u062e\u0627\u0645\u0644", diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index c1b7e5e8cbd..6c303e8e3c3 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,4 +1,9 @@ """Support for MelCloud device sensors.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW from pymelcloud.atw_device import Zone @@ -7,136 +12,153 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, - ENERGY_KILO_WATT_HOUR, - TEMP_CELSIUS, -) +from homeassistant.const import ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS from homeassistant.util import dt as dt_util from . import MelCloudDevice from .const import DOMAIN -ATTR_MEASUREMENT_NAME = "measurement_name" -ATTR_UNIT = "unit" -ATTR_VALUE_FN = "value_fn" -ATTR_ENABLED_FN = "enabled" -ATA_SENSORS = { - "room_temperature": { - ATTR_MEASUREMENT_NAME: "Room Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.room_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "energy": { - ATTR_MEASUREMENT_NAME: "Energy", - ATTR_ICON: "mdi:factory", - ATTR_UNIT: ENERGY_KILO_WATT_HOUR, - ATTR_DEVICE_CLASS: DEVICE_CLASS_ENERGY, - ATTR_VALUE_FN: lambda x: x.device.total_energy_consumed, - ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, - }, -} -ATW_SENSORS = { - "outside_temperature": { - ATTR_MEASUREMENT_NAME: "Outside Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.outside_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "tank_temperature": { - ATTR_MEASUREMENT_NAME: "Tank Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda x: x.device.tank_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, -} -ATW_ZONE_SENSORS = { - "room_temperature": { - ATTR_MEASUREMENT_NAME: "Room Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.room_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "flow_temperature": { - ATTR_MEASUREMENT_NAME: "Flow Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.flow_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, - "return_temperature": { - ATTR_MEASUREMENT_NAME: "Flow Return Temperature", - ATTR_ICON: "mdi:thermometer", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_VALUE_FN: lambda zone: zone.return_temperature, - ATTR_ENABLED_FN: lambda x: True, - }, -} +@dataclass +class MelcloudRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], float] + enabled: Callable[[Any], bool] + + +@dataclass +class MelcloudSensorEntityDescription( + SensorEntityDescription, MelcloudRequiredKeysMixin +): + """Describes Melcloud sensor entity.""" + + +ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( + MelcloudSensorEntityDescription( + key="room_temperature", + name="Room Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.room_temperature, + enabled=lambda x: True, + ), + MelcloudSensorEntityDescription( + key="energy", + name="Energy", + icon="mdi:factory", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + value_fn=lambda x: x.device.total_energy_consumed, + enabled=lambda x: x.device.has_energy_consumed_meter, + ), +) +ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( + MelcloudSensorEntityDescription( + key="outside_temperature", + name="Outside Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.outside_temperature, + enabled=lambda x: True, + ), + MelcloudSensorEntityDescription( + key="tank_temperature", + name="Tank Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda x: x.device.tank_temperature, + enabled=lambda x: True, + ), +) +ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( + MelcloudSensorEntityDescription( + key="room_temperature", + name="Room Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.room_temperature, + enabled=lambda x: True, + ), + MelcloudSensorEntityDescription( + key="flow_temperature", + name="Flow Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.flow_temperature, + enabled=lambda x: True, + ), + MelcloudSensorEntityDescription( + key="return_temperature", + name="Flow Return Temperature", + icon="mdi:thermometer", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + value_fn=lambda zone: zone.return_temperature, + enabled=lambda x: True, + ), +) async def async_setup_entry(hass, entry, async_add_entities): """Set up MELCloud device sensors based on config_entry.""" mel_devices = hass.data[DOMAIN].get(entry.entry_id) - async_add_entities( + + entities: list[MelDeviceSensor] = [ + MelDeviceSensor(mel_device, description) + for description in ATA_SENSORS + for mel_device in mel_devices[DEVICE_TYPE_ATA] + if description.enabled(mel_device) + ] + [ + MelDeviceSensor(mel_device, description) + for description in ATW_SENSORS + for mel_device in mel_devices[DEVICE_TYPE_ATW] + if description.enabled(mel_device) + ] + entities.extend( [ - MelDeviceSensor(mel_device, measurement, definition) - for measurement, definition in ATA_SENSORS.items() - for mel_device in mel_devices[DEVICE_TYPE_ATA] - if definition[ATTR_ENABLED_FN](mel_device) - ] - + [ - MelDeviceSensor(mel_device, measurement, definition) - for measurement, definition in ATW_SENSORS.items() - for mel_device in mel_devices[DEVICE_TYPE_ATW] - if definition[ATTR_ENABLED_FN](mel_device) - ] - + [ - AtwZoneSensor(mel_device, zone, measurement, definition) + AtwZoneSensor(mel_device, zone, description) for mel_device in mel_devices[DEVICE_TYPE_ATW] for zone in mel_device.device.zones - for measurement, definition, in ATW_ZONE_SENSORS.items() - if definition[ATTR_ENABLED_FN](zone) - ], - True, + for description in ATW_ZONE_SENSORS + if description.enabled(zone) + ] ) + async_add_entities(entities, True) class MelDeviceSensor(SensorEntity): """Representation of a Sensor.""" - def __init__(self, api: MelCloudDevice, measurement, definition): + entity_description: MelcloudSensorEntityDescription + + def __init__( + self, + api: MelCloudDevice, + description: MelcloudSensorEntityDescription, + ) -> None: """Initialize the sensor.""" self._api = api - self._def = definition + self.entity_description = description - self._attr_device_class = definition[ATTR_DEVICE_CLASS] - self._attr_icon = definition[ATTR_ICON] - self._attr_name = f"{api.name} {definition[ATTR_MEASUREMENT_NAME]}" - self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{measurement}" - self._attr_unit_of_measurement = definition[ATTR_UNIT] + self._attr_name = f"{api.name} {description.name}" + self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}" self._attr_state_class = STATE_CLASS_MEASUREMENT - if self.device_class == DEVICE_CLASS_ENERGY: + if description.device_class == DEVICE_CLASS_ENERGY: self._attr_last_reset = dt_util.utc_from_timestamp(0) @property def state(self): """Return the state of the sensor.""" - return self._def[ATTR_VALUE_FN](self._api) + return self.entity_description.value_fn(self._api) async def async_update(self): """Retrieve latest state.""" @@ -151,17 +173,20 @@ class MelDeviceSensor(SensorEntity): class AtwZoneSensor(MelDeviceSensor): """Air-to-Air device sensor.""" - def __init__(self, api: MelCloudDevice, zone: Zone, measurement, definition): + def __init__( + self, + api: MelCloudDevice, + zone: Zone, + description: MelcloudSensorEntityDescription, + ) -> None: """Initialize the sensor.""" - if zone.zone_index == 1: - full_measurement = measurement - else: - full_measurement = f"{measurement}-zone-{zone.zone_index}" - super().__init__(api, full_measurement, definition) + if zone.zone_index != 1: + description.key = f"{description.key}-zone-{zone.zone_index}" + super().__init__(api, description) self._zone = zone - self._attr_name = f"{api.name} {zone.name} {definition[ATTR_MEASUREMENT_NAME]}" + self._attr_name = f"{api.name} {zone.name} {description.name}" @property def state(self): """Return zone based state.""" - return self._def[ATTR_VALUE_FN](self._zone) + return self.entity_description.value_fn(self._zone) diff --git a/homeassistant/components/melcloud/translations/de.json b/homeassistant/components/melcloud/translations/de.json index 54ae78f8680..e983af740e2 100644 --- a/homeassistant/components/melcloud/translations/de.json +++ b/homeassistant/components/melcloud/translations/de.json @@ -12,10 +12,10 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, - "description": "Verbinden Sie sich mit Ihrem MELCloud-Konto.", - "title": "Stellen Sie eine Verbindung zu MELCloud her" + "description": "Verbinde dich mit deinem MELCloud-Konto.", + "title": "Stelle eine Verbindung zu MELCloud her" } } } diff --git a/homeassistant/components/met/translations/hu.json b/homeassistant/components/met/translations/hu.json index b9141541a93..38c84a3f8dc 100644 --- a/homeassistant/components/met/translations/hu.json +++ b/homeassistant/components/met/translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "no_home": "A Home Assistant konfigur\u00e1ci\u00f3j\u00e1ban nincsenek megadva otthoni koordin\u00e1t\u00e1k" + }, "error": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" }, diff --git a/homeassistant/components/met_eireann/translations/ar.json b/homeassistant/components/met_eireann/translations/ar.json new file mode 100644 index 00000000000..22ba080793b --- /dev/null +++ b/homeassistant/components/met_eireann/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "\u0623\u062f\u062e\u0644 \u0645\u0648\u0642\u0639\u0643 \u0644\u0627\u0633\u062a\u062e\u062f\u0627\u0645 \u0628\u064a\u0627\u0646\u0627\u062a \u0627\u0644\u0637\u0642\u0633 \u0645\u0646 Met \u00c9ireann Public Weather Forecast API" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/met_eireann/translations/de.json b/homeassistant/components/met_eireann/translations/de.json index a7e1ff9668b..7ce46bd36ab 100644 --- a/homeassistant/components/met_eireann/translations/de.json +++ b/homeassistant/components/met_eireann/translations/de.json @@ -11,7 +11,7 @@ "longitude": "L\u00e4ngengrad", "name": "Name" }, - "description": "Geben Sie Ihren Standort ein, um Wetterdaten von der Met \u00c9ireann Public Weather Forecast API zu verwenden", + "description": "Gib deinen Standort ein, um Wetterdaten von der Met \u00c9ireann Public Weather Forecast API zu verwenden", "title": "Standort" } } diff --git a/homeassistant/components/met_eireann/translations/hu.json b/homeassistant/components/met_eireann/translations/hu.json index 65108e183a9..b70aa2dcf67 100644 --- a/homeassistant/components/met_eireann/translations/hu.json +++ b/homeassistant/components/met_eireann/translations/hu.json @@ -11,6 +11,7 @@ "longitude": "Hossz\u00fas\u00e1g", "name": "N\u00e9v" }, + "description": "Adja meg tart\u00f3zkod\u00e1si hely\u00e9t a Met \u00c9ireann Public Weather Forecast API id\u0151j\u00e1r\u00e1si adatainak haszn\u00e1lat\u00e1hoz", "title": "Elhelyezked\u00e9s" } } diff --git a/homeassistant/components/meteo_france/translations/de.json b/homeassistant/components/meteo_france/translations/de.json index e1993b466dc..04d038cb65d 100644 --- a/homeassistant/components/meteo_france/translations/de.json +++ b/homeassistant/components/meteo_france/translations/de.json @@ -19,7 +19,7 @@ "data": { "city": "Stadt" }, - "description": "Geben Sie die Postleitzahl (nur f\u00fcr Frankreich empfohlen) oder den St\u00e4dtenamen ein", + "description": "Gib die Postleitzahl (nur f\u00fcr Frankreich empfohlen) oder den St\u00e4dtenamen ein", "title": "M\u00e9t\u00e9o-France" } } diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index bcd597d4b0c..101b889498d 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistantType, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteoclimatic sensor platform.""" coordinator = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/meteoclimatic/translations/de.json b/homeassistant/components/meteoclimatic/translations/de.json index e23662146b2..c9b9ea90e61 100644 --- a/homeassistant/components/meteoclimatic/translations/de.json +++ b/homeassistant/components/meteoclimatic/translations/de.json @@ -12,7 +12,7 @@ "data": { "code": "Stationscode" }, - "description": "Geben Sie den Code der Meteoclimatic-Station ein (z. B. ESCAT4300000043206B)", + "description": "Gib den Code der Meteoclimatic-Station ein (z. B. ESCAT4300000043206B)", "title": "Meteoclimatic" } } diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index 251fcbe8e09..fd9a38db804 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -5,7 +5,7 @@ "data": { "code": "C\u00f3digo de la estaci\u00f3n" }, - "description": "Introduzca el c\u00f3digo de la estaci\u00f3n Meteoclimatic (por ejemplo, ESCAT430000000043206B)", + "description": "Introduzca el c\u00f3digo de la estaci\u00f3n Meteoclimatic (por ejemplo, ESCAT4300000043206B)", "title": "Meteoclimatic" } } diff --git a/homeassistant/components/meteoclimatic/translations/fr.json b/homeassistant/components/meteoclimatic/translations/fr.json new file mode 100644 index 00000000000..ae087db2e6e --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "unknown": "Erreur inattendue" + }, + "error": { + "not_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau" + }, + "step": { + "user": { + "data": { + "code": "Code de la station" + }, + "description": "Entrer le code de la station m\u00e9t\u00e9orologique (par exemple, ESCAT4300000043206)", + "title": "M\u00e9t\u00e9oclimatique" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/gl.json b/homeassistant/components/meteoclimatic/translations/gl.json new file mode 100644 index 00000000000..be1629dd6e4 --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/gl.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "Introduza o c\u00f3digo da estaci\u00f3n Meteoclimatic (por exemplo, ESCAT4300000043206B)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/hu.json b/homeassistant/components/meteoclimatic/translations/hu.json index 893a6693c01..582c78f263d 100644 --- a/homeassistant/components/meteoclimatic/translations/hu.json +++ b/homeassistant/components/meteoclimatic/translations/hu.json @@ -6,6 +6,15 @@ }, "error": { "not_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + }, + "step": { + "user": { + "data": { + "code": "\u00c1llom\u00e1s k\u00f3dja" + }, + "description": "Adja meg a meteorol\u00f3giai \u00e1llom\u00e1s k\u00f3dj\u00e1t (pl. ESCAT4300000043206B)", + "title": "Meteoklimatikus" + } } } } \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/nl.json b/homeassistant/components/meteoclimatic/translations/nl.json index 0b4aa397276..dd2a318ec37 100644 --- a/homeassistant/components/meteoclimatic/translations/nl.json +++ b/homeassistant/components/meteoclimatic/translations/nl.json @@ -12,7 +12,7 @@ "data": { "code": "Station code" }, - "description": "Voer de code van het meteoklimatologische station in (bv. ESCAT43000043206B)", + "description": "Voer de code van het meteoklimatologische station in (bv. ESCAT4300000043206B)", "title": "Meteoclimatic" } } diff --git a/homeassistant/components/meteoclimatic/translations/pt.json b/homeassistant/components/meteoclimatic/translations/pt.json new file mode 100644 index 00000000000..71eded9f5de --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/pt.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "Introduza o c\u00f3digo da esta\u00e7\u00e3o Meteoclimatic (por exemplo, ESCAT4300000043206B)" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 6b45dac22e7..749282b1a21 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -1,5 +1,7 @@ """Support for UK Met Office weather service.""" -from homeassistant.components.sensor import SensorEntity +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_HUMIDITY, @@ -34,52 +36,105 @@ ATTR_SENSOR_ID = "sensor_id" ATTR_SITE_ID = "site_id" ATTR_SITE_NAME = "site_name" -# Sensor types are defined as: -# variable -> [0]title, [1]device_class, [2]units, [3]icon, [4]enabled_by_default -SENSOR_TYPES = { - "name": ["Station Name", None, None, "mdi:label-outline", False], - "weather": [ - "Weather", - None, - None, - "mdi:weather-sunny", # but will adapt to current conditions - True, - ], - "temperature": ["Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None, True], - "feels_like_temperature": [ - "Feels Like Temperature", - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, - None, - False, - ], - "wind_speed": [ - "Wind Speed", - None, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - True, - ], - "wind_direction": ["Wind Direction", None, None, "mdi:compass-outline", False], - "wind_gust": ["Wind Gust", None, SPEED_MILES_PER_HOUR, "mdi:weather-windy", False], - "visibility": ["Visibility", None, None, "mdi:eye", False], - "visibility_distance": [ - "Visibility Distance", - None, - LENGTH_KILOMETERS, - "mdi:eye", - False, - ], - "uv": ["UV Index", None, UV_INDEX, "mdi:weather-sunny-alert", True], - "precipitation": [ - "Probability of Precipitation", - None, - PERCENTAGE, - "mdi:weather-rainy", - True, - ], - "humidity": ["Humidity", DEVICE_CLASS_HUMIDITY, PERCENTAGE, None, False], -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="name", + name="Station Name", + device_class=None, + unit_of_measurement=None, + icon="mdi:label-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="weather", + name="Weather", + device_class=None, + unit_of_measurement=None, + icon="mdi:weather-sunny", # but will adapt to current conditions + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="temperature", + name="Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None, + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="feels_like_temperature", + name="Feels Like Temperature", + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + icon=None, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="wind_speed", + name="Wind Speed", + device_class=None, + unit_of_measurement=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy", + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="wind_direction", + name="Wind Direction", + device_class=None, + unit_of_measurement=None, + icon="mdi:compass-outline", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="wind_gust", + name="Wind Gust", + device_class=None, + unit_of_measurement=SPEED_MILES_PER_HOUR, + icon="mdi:weather-windy", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="visibility", + name="Visibility", + device_class=None, + unit_of_measurement=None, + icon="mdi:eye", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="visibility_distance", + name="Visibility Distance", + device_class=None, + unit_of_measurement=LENGTH_KILOMETERS, + icon="mdi:eye", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="uv", + name="UV Index", + device_class=None, + unit_of_measurement=UV_INDEX, + icon="mdi:weather-sunny-alert", + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="precipitation", + name="Probability of Precipitation", + device_class=None, + unit_of_measurement=PERCENTAGE, + icon="mdi:weather-rainy", + entity_registry_enabled_default=True, + ), + SensorEntityDescription( + key="humidity", + name="Humidity", + device_class=DEVICE_CLASS_HUMIDITY, + unit_of_measurement=PERCENTAGE, + icon=None, + entity_registry_enabled_default=False, + ), +) async def async_setup_entry( @@ -91,15 +146,21 @@ async def async_setup_entry( async_add_entities( [ MetOfficeCurrentSensor( - hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True, sensor_type + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + True, + description, ) - for sensor_type in SENSOR_TYPES + for description in SENSOR_TYPES ] + [ MetOfficeCurrentSensor( - hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False, sensor_type + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data, + False, + description, ) - for sensor_type in SENSOR_TYPES + for description in SENSOR_TYPES ], False, ) @@ -108,75 +169,64 @@ async def async_setup_entry( class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): """Implementation of a Met Office current weather condition sensor.""" - def __init__(self, coordinator, hass_data, use_3hourly, sensor_type): + def __init__( + self, + coordinator, + hass_data, + use_3hourly, + description: SensorEntityDescription, + ): """Initialize the sensor.""" super().__init__(coordinator) - self._type = sensor_type + self.entity_description = description mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL - self._name = ( - f"{hass_data[METOFFICE_NAME]} {SENSOR_TYPES[self._type][0]} {mode_label}" - ) - self._unique_id = ( - f"{SENSOR_TYPES[self._type][0]}_{hass_data[METOFFICE_COORDINATES]}" - ) + self._attr_name = f"{hass_data[METOFFICE_NAME]} {description.name} {mode_label}" + self._attr_unique_id = f"{description.name}_{hass_data[METOFFICE_COORDINATES]}" if not use_3hourly: - self._unique_id = f"{self._unique_id}_{MODE_DAILY}" + self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" self.use_3hourly = use_3hourly - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique of the sensor.""" - return self._unique_id - @property def state(self): """Return the state of the sensor.""" value = None - if self._type == "visibility_distance" and hasattr( + if self.entity_description.key == "visibility_distance" and hasattr( self.coordinator.data.now, "visibility" ): value = VISIBILITY_DISTANCE_CLASSES.get( self.coordinator.data.now.visibility.value ) - if self._type == "visibility" and hasattr( + if self.entity_description.key == "visibility" and hasattr( self.coordinator.data.now, "visibility" ): value = VISIBILITY_CLASSES.get(self.coordinator.data.now.visibility.value) - elif self._type == "weather" and hasattr(self.coordinator.data.now, self._type): + elif self.entity_description.key == "weather" and hasattr( + self.coordinator.data.now, self.entity_description.key + ): value = [ k for k, v in CONDITION_CLASSES.items() if self.coordinator.data.now.weather.value in v ][0] - elif hasattr(self.coordinator.data.now, self._type): - value = getattr(self.coordinator.data.now, self._type) + elif hasattr(self.coordinator.data.now, self.entity_description.key): + value = getattr(self.coordinator.data.now, self.entity_description.key) - if not isinstance(value, int): + if hasattr(value, "value"): value = value.value return value - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][2] - @property def icon(self): """Return the icon for the entity card.""" - value = SENSOR_TYPES[self._type][3] - if self._type == "weather": + value = self.entity_description.icon + if self.entity_description.key == "weather": value = self.state if value is None: value = "sunny" @@ -186,18 +236,13 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): return value - @property - def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self._type][1] - @property def extra_state_attributes(self): """Return the state attributes of the device.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_LAST_UPDATE: self.coordinator.data.now.date, - ATTR_SENSOR_ID: self._type, + ATTR_SENSOR_ID: self.entity_description.key, ATTR_SITE_ID: self.coordinator.data.site.id, ATTR_SITE_NAME: self.coordinator.data.site.name, } @@ -205,4 +250,6 @@ class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity): @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" - return SENSOR_TYPES[self._type][4] and self.use_3hourly + return ( + self.entity_description.entity_registry_enabled_default and self.use_3hourly + ) diff --git a/homeassistant/components/metoffice/translations/ar.json b/homeassistant/components/metoffice/translations/ar.json new file mode 100644 index 00000000000..32617bb5576 --- /dev/null +++ b/homeassistant/components/metoffice/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "description": "\u0633\u064a\u062a\u0645 \u0627\u0633\u062a\u062e\u062f\u0627\u0645 \u062e\u0637\u0648\u0637 \u0627\u0644\u0637\u0648\u0644 \u0648\u0627\u0644\u0639\u0631\u0636 \u0644\u0644\u0639\u062b\u0648\u0631 \u0639\u0644\u0649 \u0623\u0642\u0631\u0628 \u0645\u062d\u0637\u0629 \u0637\u0642\u0633." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/translations/de.json b/homeassistant/components/metoffice/translations/de.json index 8f35c2aaeaa..2c28d0742ce 100644 --- a/homeassistant/components/metoffice/translations/de.json +++ b/homeassistant/components/metoffice/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -10,7 +10,7 @@ "step": { "user": { "data": { - "api_key": "API Key", + "api_key": "API-Schl\u00fcssel", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad" }, diff --git a/homeassistant/components/metoffice/translations/hu.json b/homeassistant/components/metoffice/translations/hu.json index 350e6f92f32..8e4c2dcb8db 100644 --- a/homeassistant/components/metoffice/translations/hu.json +++ b/homeassistant/components/metoffice/translations/hu.json @@ -14,6 +14,7 @@ "latitude": "Sz\u00e9less\u00e9g", "longitude": "Hossz\u00fas\u00e1g" }, + "description": "A f\u00f6ldrajzi sz\u00e9less\u00e9get \u00e9s hossz\u00fas\u00e1got haszn\u00e1ljuk a legk\u00f6zelebbi id\u0151j\u00e1r\u00e1s-\u00e1llom\u00e1s megtal\u00e1l\u00e1s\u00e1hoz.", "title": "Csatlakoz\u00e1s a UK Met Office-hoz" } } diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py index c7a64f17bd6..fafaf53ff99 100644 --- a/homeassistant/components/mfi/sensor.py +++ b/homeassistant/components/mfi/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, + DEVICE_CLASS_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS, @@ -83,7 +84,7 @@ class MfiSensor(SensorEntity): @property def name(self): - """Return the name of th sensor.""" + """Return the name of the sensor.""" return self._port.label @property @@ -100,6 +101,19 @@ class MfiSensor(SensorEntity): digits = DIGITS.get(self._port.tag, 0) return round(self._port.value, digits) + @property + def device_class(self): + """Return the device class of the sensor.""" + try: + tag = self._port.tag + except ValueError: + return None + + if tag == "temperature": + return DEVICE_CLASS_TEMPERATURE + + return None + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index 0f0735dd5da..63a1181f720 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -11,6 +11,8 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, CONF_NAME, + DEVICE_CLASS_CO2, + DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT, ) import homeassistant.helpers.config_validation as cv @@ -29,8 +31,8 @@ ATTR_CO2_CONCENTRATION = "co2_concentration" SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" SENSOR_TYPES = { - SENSOR_TEMPERATURE: ["Temperature", None], - SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION], + SENSOR_TEMPERATURE: ["Temperature", None, DEVICE_CLASS_TEMPERATURE], + SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -80,6 +82,7 @@ class MHZ19Sensor(SensorEntity): self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._ppm = None self._temperature = None + self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property def name(self): diff --git a/homeassistant/components/mikrotik/translations/de.json b/homeassistant/components/mikrotik/translations/de.json index 1c11717c1b2..1a9c3b5d352 100644 --- a/homeassistant/components/mikrotik/translations/de.json +++ b/homeassistant/components/mikrotik/translations/de.json @@ -16,9 +16,9 @@ "password": "Passwort", "port": "Port", "username": "Benutzername", - "verify_ssl": "Verwenden Sie SSL" + "verify_ssl": "SSL verwenden" }, - "title": "Richten Sie den Mikrotik Router ein" + "title": "Mikrotik Router einrichten" } } }, @@ -28,7 +28,7 @@ "data": { "arp_ping": "ARP-Ping aktivieren", "detection_time": "Heimintervall ber\u00fccksichtigen", - "force_dhcp": "Erzwingen Sie das Scannen \u00fcber DHCP" + "force_dhcp": "Scannen mit DHCP erzwingen" } } } diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index 115bb5eb33c..75422cd26e1 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -1,10 +1,29 @@ """The mill component.""" +from mill import Mill -PLATFORMS = ["climate"] +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +PLATFORMS = ["climate", "sensor"] async def async_setup_entry(hass, entry): """Set up the Mill heater.""" + mill_data_connection = Mill( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + websession=async_get_clientsession(hass), + ) + if not await mill_data_connection.connect(): + raise ConfigEntryNotReady + + await mill_data_connection.find_all_heaters() + + hass.data[DOMAIN] = mill_data_connection + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 2d591c67668..16c78329b0b 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,5 +1,4 @@ """Support for mill wifi-enabled home heaters.""" -from mill import Mill import voluptuous as vol from homeassistant.components.climate import ClimateEntity @@ -12,15 +11,8 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_USERNAME, - TEMP_CELSIUS, -) -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ATTR_AWAY_TEMP, @@ -48,15 +40,8 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema( async def async_setup_entry(hass, entry, async_add_entities): """Set up the Mill climate.""" - mill_data_connection = Mill( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - websession=async_get_clientsession(hass), - ) - if not await mill_data_connection.connect(): - raise ConfigEntryNotReady - await mill_data_connection.find_all_heaters() + mill_data_connection = hass.data[DOMAIN] dev = [] for heater in mill_data_connection.heaters.values(): @@ -109,8 +94,6 @@ class MillHeater(ClimateEntity): "heating": self._heater.is_heating, "controlled_by_tibber": self._heater.tibber_control, "heater_generation": 1 if self._heater.is_gen1 else 2, - "consumption_today": self._heater.day_consumption, - "consumption_total": self._heater.year_consumption, } if self._heater.room: res["room"] = self._heater.room.name diff --git a/homeassistant/components/mill/const.py b/homeassistant/components/mill/const.py index b0ba7065e0a..61171420e44 100644 --- a/homeassistant/components/mill/const.py +++ b/homeassistant/components/mill/const.py @@ -4,8 +4,10 @@ ATTR_AWAY_TEMP = "away_temp" ATTR_COMFORT_TEMP = "comfort_temp" ATTR_ROOM_NAME = "room_name" ATTR_SLEEP_TEMP = "sleep_temp" +CONSUMPTION_TODAY = "consumption_today" +CONSUMPTION_YEAR = "consumption_year" +DOMAIN = "mill" MANUFACTURER = "Mill" MAX_TEMP = 35 MIN_TEMP = 5 -DOMAIN = "mill" SERVICE_SET_ROOM_TEMP = "set_room_temperature" diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py new file mode 100644 index 00000000000..8b68d0ebe38 --- /dev/null +++ b/homeassistant/components/mill/sensor.py @@ -0,0 +1,85 @@ +"""Support for mill wifi-enabled home heaters.""" + +from homeassistant.components.sensor import ( + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) +from homeassistant.const import ENERGY_KILO_WATT_HOUR, STATE_UNKNOWN +from homeassistant.util import dt as dt_util + +from .const import CONSUMPTION_TODAY, CONSUMPTION_YEAR, DOMAIN, MANUFACTURER + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Mill sensor.""" + + mill_data_connection = hass.data[DOMAIN] + + dev = [] + for heater in mill_data_connection.heaters.values(): + for sensor_type in (CONSUMPTION_TODAY, CONSUMPTION_YEAR): + dev.append( + MillHeaterEnergySensor(heater, mill_data_connection, sensor_type) + ) + async_add_entities(dev) + + +class MillHeaterEnergySensor(SensorEntity): + """Representation of a Mill Sensor device.""" + + def __init__(self, heater, mill_data_connection, sensor_type): + """Initialize the sensor.""" + self._id = heater.device_id + self._conn = mill_data_connection + self._sensor_type = sensor_type + + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_name = f"{heater.name} {sensor_type.replace('_', ' ')}" + self._attr_unique_id = f"{heater.device_id}_{sensor_type}" + self._attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_device_info = { + "identifiers": {(DOMAIN, heater.device_id)}, + "name": self.name, + "manufacturer": MANUFACTURER, + "model": f"generation {1 if heater.is_gen1 else 2}", + } + if self._sensor_type == CONSUMPTION_TODAY: + self._attr_last_reset = dt_util.as_utc( + dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + ) + elif self._sensor_type == CONSUMPTION_YEAR: + self._attr_last_reset = dt_util.as_utc( + dt_util.now().replace( + month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + ) + + async def async_update(self): + """Retrieve latest state.""" + heater = await self._conn.update_device(self._id) + self._attr_available = heater.available + + if self._sensor_type == CONSUMPTION_TODAY: + _state = heater.day_consumption + elif self._sensor_type == CONSUMPTION_YEAR: + _state = heater.year_consumption + else: + _state = None + if _state is None: + self._attr_state = _state + return + + if self.state not in [STATE_UNKNOWN, None] and _state < self.state: + if self._sensor_type == CONSUMPTION_TODAY: + self._attr_last_reset = dt_util.as_utc( + dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + ) + elif self._sensor_type == CONSUMPTION_YEAR: + self._attr_last_reset = dt_util.as_utc( + dt_util.now().replace( + month=1, day=1, hour=0, minute=0, second=0, microsecond=0 + ) + ) + self._attr_state = _state diff --git a/homeassistant/components/mill/translations/de.json b/homeassistant/components/mill/translations/de.json index 63b6b7ea6e9..44d9c2448e6 100644 --- a/homeassistant/components/mill/translations/de.json +++ b/homeassistant/components/mill/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Account ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index d850d9ab469..a59f9bf28cf 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -3,7 +3,7 @@ "name": "Mobile App", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mobile_app", - "requirements": ["PyNaCl==1.3.0", "emoji==1.2.0"], + "requirements": ["PyNaCl==1.4.0", "emoji==1.2.0"], "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], "after_dependencies": ["cloud", "camera", "notify"], "codeowners": ["@robbiet480"], diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 1acb9f25c0c..162ec8afeab 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -79,7 +79,7 @@ def log_rate_limits(hass, device_name, resp, level=logging.INFO): rate_limits[ATTR_PUSH_RATE_LIMITS_SUCCESSFUL], rate_limits[ATTR_PUSH_RATE_LIMITS_MAXIMUM], rate_limits[ATTR_PUSH_RATE_LIMITS_ERRORS], - str(resetsAtTime).split(".")[0], + str(resetsAtTime).split(".", maxsplit=1)[0], ) diff --git a/homeassistant/components/mobile_app/translations/he.json b/homeassistant/components/mobile_app/translations/he.json new file mode 100644 index 00000000000..e213f54137c --- /dev/null +++ b/homeassistant/components/mobile_app/translations/he.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05e8\u05db\u05d9\u05d1 \u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05dc\u05e0\u05d9\u05d9\u05d3?" + } + } + }, + "title": "\u05d9\u05d9\u05e9\u05d5\u05dd \u05dc\u05e0\u05d9\u05d9\u05d3" +} \ No newline at end of file diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 64f10d5616a..99bb153f3ee 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -268,7 +268,7 @@ async def webhook_stream_camera(hass, config_entry, data): status=HTTP_BAD_REQUEST, ) - resp = {"mjpeg_path": "/api/camera_proxy_stream/%s" % (camera.entity_id)} + resp = {"mjpeg_path": f"/api/camera_proxy_stream/{camera.entity_id}"} if camera.attributes[ATTR_SUPPORTED_FEATURES] & CAMERA_SUPPORT_STREAM: try: diff --git a/homeassistant/components/mochad/switch.py b/homeassistant/components/mochad/switch.py index e7f1bee99f6..d23d46c8392 100644 --- a/homeassistant/components/mochad/switch.py +++ b/homeassistant/components/mochad/switch.py @@ -44,7 +44,7 @@ class MochadSwitch(SwitchEntity): self._controller = ctrl self._address = dev[CONF_ADDRESS] - self._name = dev.get(CONF_NAME, "x10_switch_dev_%s" % self._address) + self._name = dev.get(CONF_NAME, f"x10_switch_dev_{self._address}") self._comm_type = dev.get(CONF_COMM_TYPE, "pl") self.switch = device.Device(ctrl, self._address, comm_type=self._comm_type) # Init with false to avoid locking HA for long on CM19A (goes from rf diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index e60bbbda78b..16be39230db 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -54,6 +54,8 @@ from .const import ( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CALL_TYPE_X_COILS, + CALL_TYPE_X_REGISTER_HOLDINGS, CONF_BAUDRATE, CONF_BYTESIZE, CONF_CLIMATES, @@ -94,20 +96,25 @@ from .const import ( CONF_WRITE_TYPE, DATA_TYPE_CUSTOM, DATA_TYPE_FLOAT, + DATA_TYPE_FLOAT16, + DATA_TYPE_FLOAT32, + DATA_TYPE_FLOAT64, DATA_TYPE_INT, + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, DATA_TYPE_STRING, DATA_TYPE_UINT, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, DEFAULT_TEMP_UNIT, MODBUS_DOMAIN as DOMAIN, ) from .modbus import async_modbus_setup -from .validators import ( - number_validator, - scan_interval_validator, - sensor_schema_validator, -) +from .validators import number_validator, scan_interval_validator, struct_validator _LOGGER = logging.getLogger(__name__) @@ -134,9 +141,19 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CALL_TYPE_REGISTER_INPUT, ] ), - vol.Optional(CONF_COUNT, default=1): cv.positive_int, + vol.Optional(CONF_COUNT): cv.positive_int, vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT): vol.In( [ + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, + DATA_TYPE_FLOAT16, + DATA_TYPE_FLOAT32, + DATA_TYPE_FLOAT64, + DATA_TYPE_STRING, DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT, @@ -166,6 +183,8 @@ BASE_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( [ CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_COIL, + CALL_TYPE_X_COILS, + CALL_TYPE_X_REGISTER_HOLDINGS, ] ), vol.Optional(CONF_COMMAND_OFF, default=0x00): cv.positive_int, @@ -179,6 +198,8 @@ BASE_SWITCH_SCHEMA = BASE_COMPONENT_SCHEMA.extend( CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_INPUT, CALL_TYPE_COIL, + CALL_TYPE_X_COILS, + CALL_TYPE_X_REGISTER_HOLDINGS, ] ), vol.Optional(CONF_STATE_OFF): cv.positive_int, @@ -266,12 +287,12 @@ MODBUS_SCHEMA = vol.Schema( cv.ensure_list, [BINARY_SENSOR_SCHEMA] ), vol.Optional(CONF_CLIMATES): vol.All( - cv.ensure_list, [vol.All(CLIMATE_SCHEMA, sensor_schema_validator)] + cv.ensure_list, [vol.All(CLIMATE_SCHEMA, struct_validator)] ), vol.Optional(CONF_COVERS): vol.All(cv.ensure_list, [COVERS_SCHEMA]), vol.Optional(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHT_SCHEMA]), vol.Optional(CONF_SENSORS): vol.All( - cv.ensure_list, [vol.All(SENSOR_SCHEMA, sensor_schema_validator)] + cv.ensure_list, [vol.All(SENSOR_SCHEMA, struct_validator)] ), vol.Optional(CONF_SWITCHES): vol.All(cv.ensure_list, [SWITCH_SCHEMA]), vol.Optional(CONF_FANS): vol.All(cv.ensure_list, [FAN_SCHEMA]), diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 4ff7adbdd29..b321183fd66 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -4,17 +4,21 @@ from __future__ import annotations from abc import abstractmethod from datetime import timedelta import logging +import struct from typing import Any from homeassistant.const import ( CONF_ADDRESS, CONF_COMMAND_OFF, CONF_COMMAND_ON, + CONF_COUNT, CONF_DELAY, CONF_DEVICE_CLASS, CONF_NAME, + CONF_OFFSET, CONF_SCAN_INTERVAL, CONF_SLAVE, + CONF_STRUCTURE, STATE_ON, ) from homeassistant.helpers.entity import Entity @@ -23,13 +27,26 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( CALL_TYPE_COIL, + CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_COIL, + CALL_TYPE_WRITE_COILS, CALL_TYPE_WRITE_REGISTER, + CALL_TYPE_WRITE_REGISTERS, + CALL_TYPE_X_COILS, + CALL_TYPE_X_REGISTER_HOLDINGS, + CONF_DATA_TYPE, CONF_INPUT_TYPE, + CONF_PRECISION, + CONF_SCALE, CONF_STATE_OFF, CONF_STATE_ON, + CONF_SWAP, + CONF_SWAP_BYTE, + CONF_SWAP_WORD, + CONF_SWAP_WORD_BYTE, CONF_VERIFY, CONF_WRITE_TYPE, + DATA_TYPE_STRING, ) from .modbus import ModbusHub @@ -43,16 +60,19 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._name = entry[CONF_NAME] self._slave = entry.get(CONF_SLAVE) self._address = int(entry[CONF_ADDRESS]) - self._device_class = entry.get(CONF_DEVICE_CLASS) self._input_type = entry[CONF_INPUT_TYPE] self._value = None - self._available = True self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) self._call_active = False + self._attr_name = entry[CONF_NAME] + self._attr_should_poll = False + self._attr_device_class = entry.get(CONF_DEVICE_CLASS) + self._attr_available = True + self._attr_unit_of_measurement = None + @abstractmethod async def async_update(self, now=None): """Virtual function to be overwritten.""" @@ -64,25 +84,74 @@ class BasePlatform(Entity): self.hass, self.async_update, timedelta(seconds=self._scan_interval) ) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False +class BaseStructPlatform(BasePlatform, RestoreEntity): + """Base class representing a sensor/climate.""" - @property - def device_class(self) -> str | None: - """Return the device class of the sensor.""" - return self._device_class + def __init__(self, hub: ModbusHub, config: dict) -> None: + """Initialize the switch.""" + super().__init__(hub, config) + self._swap = config[CONF_SWAP] + self._data_type = config[CONF_DATA_TYPE] + self._structure = config.get(CONF_STRUCTURE) + self._precision = config[CONF_PRECISION] + self._scale = config[CONF_SCALE] + self._offset = config[CONF_OFFSET] + self._count = config[CONF_COUNT] - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available + def _swap_registers(self, registers): + """Do swap as needed.""" + if self._swap in [CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE]: + # convert [12][34] --> [21][43] + for i, register in enumerate(registers): + registers[i] = int.from_bytes( + register.to_bytes(2, byteorder="little"), + byteorder="big", + signed=False, + ) + if self._swap in [CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE]: + # convert [12][34] ==> [34][12] + registers.reverse() + return registers + + def unpack_structure_result(self, registers): + """Convert registers to proper result.""" + + registers = self._swap_registers(registers) + byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) + if self._data_type == DATA_TYPE_STRING: + self._value = byte_string.decode() + else: + val = struct.unpack(self._structure, byte_string) + + # Issue: https://github.com/home-assistant/core/issues/41944 + # If unpack() returns a tuple greater than 1, don't try to process the value. + # Instead, return the values of unpack(...) separated by commas. + if len(val) > 1: + # Apply scale and precision to floats and ints + v_result = [] + for entry in val: + v_temp = self._scale * entry + self._offset + + # We could convert int to float, and the code would still work; however + # we lose some precision, and unit tests will fail. Therefore, we do + # the conversion only when it's absolutely necessary. + if isinstance(v_temp, int) and self._precision == 0: + v_result.append(str(v_temp)) + else: + v_result.append(f"{float(v_temp):.{self._precision}f}") + self._value = ",".join(map(str, v_result)) + else: + # Apply scale and precision to floats and ints + val = self._scale * val[0] + self._offset + + # We could convert int to float, and the code would still work; however + # we lose some precision, and unit tests will fail. Therefore, we do + # the conversion only when it's absolutely necessary. + if isinstance(val, int) and self._precision == 0: + self._value = str(val) + else: + self._value = f"{float(val):.{self._precision}f}" class BaseSwitch(BasePlatform, RestoreEntity): @@ -93,10 +162,19 @@ class BaseSwitch(BasePlatform, RestoreEntity): config[CONF_INPUT_TYPE] = "" super().__init__(hub, config) self._is_on = None - if config[CONF_WRITE_TYPE] == CALL_TYPE_COIL: - self._write_type = CALL_TYPE_WRITE_COIL - else: - self._write_type = CALL_TYPE_WRITE_REGISTER + convert = { + CALL_TYPE_REGISTER_HOLDING: ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_REGISTER, + ), + CALL_TYPE_COIL: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COIL), + CALL_TYPE_X_COILS: (CALL_TYPE_COIL, CALL_TYPE_WRITE_COILS), + CALL_TYPE_X_REGISTER_HOLDINGS: ( + CALL_TYPE_REGISTER_HOLDING, + CALL_TYPE_WRITE_REGISTERS, + ), + } + self._write_type = convert[config[CONF_WRITE_TYPE]][1] self.command_on = config[CONF_COMMAND_ON] self._command_off = config[CONF_COMMAND_OFF] if CONF_VERIFY in config: @@ -108,7 +186,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): CONF_ADDRESS, config[CONF_ADDRESS] ) self._verify_type = config[CONF_VERIFY].get( - CONF_INPUT_TYPE, config[CONF_WRITE_TYPE] + CONF_INPUT_TYPE, convert[config[CONF_WRITE_TYPE]][0] ) self._state_on = config[CONF_VERIFY].get(CONF_STATE_ON, self.command_on) self._state_off = config[CONF_VERIFY].get(CONF_STATE_OFF, self._command_off) @@ -133,11 +211,11 @@ class BaseSwitch(BasePlatform, RestoreEntity): self._slave, self._address, command, self._write_type ) if result is None: - self._available = False + self._attr_available = False self.async_write_ha_state() return - self._available = True + self._attr_available = True if not self._verify_active: self._is_on = command == self.command_on self.async_write_ha_state() @@ -157,7 +235,7 @@ class BaseSwitch(BasePlatform, RestoreEntity): # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval if not self._verify_active: - self._available = True + self._attr_available = True self.async_write_ha_state() return @@ -170,11 +248,11 @@ class BaseSwitch(BasePlatform, RestoreEntity): ) self._call_active = False if result is None: - self._available = False + self._attr_available = False self.async_write_ha_state() return - self._available = True + self._attr_available = True if self._verify_type == CALL_TYPE_COIL: self._is_on = bool(result.bits[0] & 1) else: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 0188210be8a..ac635c76275 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -64,10 +64,10 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): ) self._call_active = False if result is None: - self._available = False + self._attr_available = False self.async_write_ha_state() return self._value = result.bits[0] & 1 - self._available = True + self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 42430f609cf..1353828b926 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -11,11 +11,11 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) from homeassistant.const import ( - CONF_COUNT, CONF_NAME, - CONF_OFFSET, CONF_STRUCTURE, CONF_TEMPERATURE_UNIT, + PRECISION_TENTHS, + PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -23,23 +23,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .base_platform import BasePlatform +from .base_platform import BaseStructPlatform from .const import ( ATTR_TEMPERATURE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_WRITE_REGISTERS, CONF_CLIMATES, - CONF_DATA_TYPE, CONF_MAX_TEMP, CONF_MIN_TEMP, - CONF_PRECISION, - CONF_SCALE, CONF_STEP, - CONF_SWAP, - CONF_SWAP_BYTE, - CONF_SWAP_WORD, - CONF_SWAP_WORD_BYTE, CONF_TARGET_TEMP, + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, MODBUS_DOMAIN, ) from .modbus import ModbusHub @@ -66,7 +65,7 @@ async def async_setup_platform( async_add_entities(entities) -class ModbusThermostat(BasePlatform, RestoreEntity, ClimateEntity): +class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity): """Representation of a Modbus Thermostat.""" def __init__( @@ -77,110 +76,68 @@ class ModbusThermostat(BasePlatform, RestoreEntity, ClimateEntity): """Initialize the modbus thermostat.""" super().__init__(hub, config) self._target_temperature_register = config[CONF_TARGET_TEMP] - self._target_temperature = None - self._current_temperature = None - self._data_type = config[CONF_DATA_TYPE] - self._structure = config[CONF_STRUCTURE] - self._count = config[CONF_COUNT] - self._precision = config[CONF_PRECISION] - self._scale = config[CONF_SCALE] - self._offset = config[CONF_OFFSET] self._unit = config[CONF_TEMPERATURE_UNIT] - self._max_temp = config[CONF_MAX_TEMP] - self._min_temp = config[CONF_MIN_TEMP] - self._temp_step = config[CONF_STEP] - self._swap = config[CONF_SWAP] + self._structure = config[CONF_STRUCTURE] + + self._attr_supported_features = SUPPORT_TARGET_TEMPERATURE + self._attr_hvac_mode = HVAC_MODE_AUTO + self._attr_hvac_modes = [HVAC_MODE_AUTO] + self._attr_current_temperature = None + self._attr_target_temperature = None + self._attr_temperature_unit = ( + TEMP_FAHRENHEIT if self._unit == "F" else TEMP_CELSIUS + ) + self._attr_precision = ( + PRECISION_TENTHS if self._precision >= 1 else PRECISION_WHOLE + ) + self._attr_min_temp = config[CONF_MIN_TEMP] + self._attr_max_temp = config[CONF_MAX_TEMP] + self._attr_target_temperature_step = config[CONF_TARGET_TEMP] + self._attr_target_temperature_step = config[CONF_STEP] async def async_added_to_hass(self): """Handle entity which will be added.""" await self.async_base_added_to_hass() state = await self.async_get_last_state() if state and state.attributes.get(ATTR_TEMPERATURE): - self._target_temperature = float(state.attributes[ATTR_TEMPERATURE]) - - @property - def supported_features(self): - """Return the list of supported features.""" - return SUPPORT_TARGET_TEMPERATURE - - @property - def hvac_mode(self): - """Return the current HVAC mode.""" - return HVAC_MODE_AUTO - - @property - def hvac_modes(self): - """Return the possible HVAC modes.""" - return [HVAC_MODE_AUTO] + self._attr_target_temperature = float(state.attributes[ATTR_TEMPERATURE]) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" # Home Assistant expects this method. # We'll keep it here to avoid getting exceptions. - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the target temperature.""" - return self._target_temperature - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT if self._unit == "F" else TEMP_CELSIUS - - @property - def min_temp(self): - """Return the minimum temperature.""" - return self._min_temp - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self._max_temp - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return self._temp_step - async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if ATTR_TEMPERATURE not in kwargs: return - target_temperature = int( - (kwargs.get(ATTR_TEMPERATURE) - self._offset) / self._scale - ) - byte_string = struct.pack(self._structure, target_temperature) - register_value = struct.unpack(">h", byte_string[0:2])[0] + target_temperature = ( + float(kwargs.get(ATTR_TEMPERATURE)) - self._offset + ) / self._scale + if self._data_type in [ + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, + ]: + target_temperature = int(target_temperature) + as_bytes = struct.pack(self._structure, target_temperature) + raw_regs = [ + int.from_bytes(as_bytes[i : i + 2], "big") + for i in range(0, len(as_bytes), 2) + ] + registers = self._swap_registers(raw_regs) result = await self._hub.async_pymodbus_call( self._slave, self._target_temperature_register, - register_value, + registers, CALL_TYPE_WRITE_REGISTERS, ) - self._available = result is not None + self._attr_available = result is not None await self.async_update() - def _swap_registers(self, registers): - """Do swap as needed.""" - if self._swap in [CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE]: - # convert [12][34] --> [21][43] - for i, register in enumerate(registers): - registers[i] = int.from_bytes( - register.to_bytes(2, byteorder="little"), - byteorder="big", - signed=False, - ) - if self._swap in [CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE]: - # convert [12][34] ==> [34][12] - registers.reverse() - return registers - async def async_update(self, now=None): """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with @@ -190,10 +147,10 @@ class ModbusThermostat(BasePlatform, RestoreEntity, ClimateEntity): if self._call_active: return self._call_active = True - self._target_temperature = await self._async_read_register( + self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) - self._current_temperature = await self._async_read_register( + self._attr_current_temperature = await self._async_read_register( self._input_type, self._address ) self._call_active = False @@ -205,24 +162,13 @@ class ModbusThermostat(BasePlatform, RestoreEntity, ClimateEntity): self._slave, register, self._count, register_type ) if result is None: - self._available = False + self._attr_available = False return -1 - registers = self._swap_registers(result.registers) - byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) - val = struct.unpack(self._structure, byte_string) - if len(val) != 1 or not isinstance(val[0], (float, int)): - _LOGGER.error( - "Unable to parse result as a single int or float value; adjust your configuration. Result: %s", - str(val), - ) - return -1 + self.unpack_structure_result(result.registers) - val2 = val[0] - register_value = format( - (self._scale * val2) + self._offset, f".{self._precision}f" - ) - register_value2 = float(register_value) - self._available = True + self._attr_available = True - return register_value2 + if self._value is None: + return None + return float(self._value) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 8fb4626d2fe..49b7683435e 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -79,6 +79,15 @@ DATA_TYPE_FLOAT = "float" DATA_TYPE_INT = "int" DATA_TYPE_UINT = "uint" DATA_TYPE_STRING = "string" +DATA_TYPE_INT16 = "int16" +DATA_TYPE_INT32 = "int32" +DATA_TYPE_INT64 = "int64" +DATA_TYPE_UINT16 = "uint16" +DATA_TYPE_UINT32 = "uint32" +DATA_TYPE_UINT64 = "uint64" +DATA_TYPE_FLOAT16 = "float16" +DATA_TYPE_FLOAT32 = "float32" +DATA_TYPE_FLOAT64 = "float64" # call types CALL_TYPE_COIL = "coil" @@ -89,6 +98,8 @@ CALL_TYPE_WRITE_COIL = "write_coil" CALL_TYPE_WRITE_COILS = "write_coils" CALL_TYPE_WRITE_REGISTER = "write_register" CALL_TYPE_WRITE_REGISTERS = "write_registers" +CALL_TYPE_X_COILS = "coils" +CALL_TYPE_X_REGISTER_HOLDINGS = "holdings" # service calls SERVICE_WRITE_COIL = "write_coil" @@ -100,9 +111,16 @@ DEFAULT_SCAN_INTERVAL = 15 # seconds DEFAULT_SLAVE = 1 DEFAULT_STRUCTURE_PREFIX = ">f" DEFAULT_STRUCT_FORMAT = { - DATA_TYPE_INT: {1: "h", 2: "i", 4: "q"}, - DATA_TYPE_UINT: {1: "H", 2: "I", 4: "Q"}, - DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, + DATA_TYPE_INT16: ["h", 1], + DATA_TYPE_INT32: ["i", 2], + DATA_TYPE_INT64: ["q", 4], + DATA_TYPE_UINT16: ["H", 1], + DATA_TYPE_UINT32: ["I", 2], + DATA_TYPE_UINT64: ["Q", 4], + DATA_TYPE_FLOAT16: ["e", 1], + DATA_TYPE_FLOAT32: ["f", 2], + DATA_TYPE_FLOAT64: ["d", 4], + DATA_TYPE_STRING: ["s", 1], } DEFAULT_TEMP_UNIT = "C" MODBUS_DOMAIN = "modbus" diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index bd150434dc1..98a352f218a 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -73,6 +73,8 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): self._status_register = config.get(CONF_STATUS_REGISTER) self._status_register_type = config[CONF_STATUS_REGISTER_TYPE] + self._attr_supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + # If we read cover status from coil, and not from optional status register, # we interpret boolean value False as closed cover, and value True as open cover. # Intermediate states are not supported in such a setup. @@ -109,11 +111,6 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): } self._value = convert[state.state] - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_OPEN | SUPPORT_CLOSE - @property def is_opening(self): """Return if the cover is opening or not.""" @@ -134,7 +131,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): result = await self._hub.async_pymodbus_call( self._slave, self._write_address, self._state_open, self._write_type ) - self._available = result is not None + self._attr_available = result is not None await self.async_update() async def async_close_cover(self, **kwargs: Any) -> None: @@ -142,7 +139,7 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): result = await self._hub.async_pymodbus_call( self._slave, self._write_address, self._state_closed, self._write_type ) - self._available = result is not None + self._attr_available = result is not None await self.async_update() async def async_update(self, now=None): @@ -158,10 +155,10 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): ) self._call_active = False if result is None: - self._available = False + self._attr_available = False self.async_write_ha_state() return None - self._available = True + self._attr_available = True if self._input_type == CALL_TYPE_COIL: self._value = bool(result.bits[0] & 1) else: diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index f5c7bf2df4e..9f2208de175 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/modbus", "requirements": ["pymodbus==2.5.2"], "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], + "quality_scale": "silver", "iot_class": "local_polling" } diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 9f1e7572a58..e969fa23a65 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -2,34 +2,16 @@ from __future__ import annotations import logging -import struct from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.const import ( - CONF_COUNT, - CONF_NAME, - CONF_OFFSET, - CONF_SENSORS, - CONF_STRUCTURE, - CONF_UNIT_OF_MEASUREMENT, -) +from homeassistant.const import CONF_NAME, CONF_SENSORS, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .base_platform import BasePlatform -from .const import ( - CONF_DATA_TYPE, - CONF_PRECISION, - CONF_SCALE, - CONF_SWAP, - CONF_SWAP_BYTE, - CONF_SWAP_WORD, - CONF_SWAP_WORD_BYTE, - DATA_TYPE_STRING, - MODBUS_DOMAIN, -) +from .base_platform import BaseStructPlatform +from .const import MODBUS_DOMAIN from .modbus import ModbusHub PARALLEL_UPDATES = 1 @@ -55,7 +37,7 @@ async def async_setup_platform( async_add_entities(sensors) -class ModbusRegisterSensor(BasePlatform, RestoreEntity, SensorEntity): +class ModbusRegisterSensor(BaseStructPlatform, RestoreEntity, SensorEntity): """Modbus register sensor.""" def __init__( @@ -65,14 +47,7 @@ class ModbusRegisterSensor(BasePlatform, RestoreEntity, SensorEntity): ) -> None: """Initialize the modbus register sensor.""" super().__init__(hub, entry) - self._unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) - self._count = int(entry[CONF_COUNT]) - self._swap = entry[CONF_SWAP] - self._scale = entry[CONF_SCALE] - self._offset = entry[CONF_OFFSET] - self._precision = entry[CONF_PRECISION] - self._structure = entry.get(CONF_STRUCTURE) - self._data_type = entry[CONF_DATA_TYPE] + self._attr_unit_of_measurement = entry.get(CONF_UNIT_OF_MEASUREMENT) async def async_added_to_hass(self): """Handle entity which will be added.""" @@ -86,26 +61,6 @@ class ModbusRegisterSensor(BasePlatform, RestoreEntity, SensorEntity): """Return the state of the sensor.""" return self._value - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement - - def _swap_registers(self, registers): - """Do swap as needed.""" - if self._swap in [CONF_SWAP_BYTE, CONF_SWAP_WORD_BYTE]: - # convert [12][34] --> [21][43] - for i, register in enumerate(registers): - registers[i] = int.from_bytes( - register.to_bytes(2, byteorder="little"), - byteorder="big", - signed=False, - ) - if self._swap in [CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE]: - # convert [12][34] ==> [34][12] - registers.reverse() - return registers - async def async_update(self, now=None): """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with @@ -114,45 +69,10 @@ class ModbusRegisterSensor(BasePlatform, RestoreEntity, SensorEntity): self._slave, self._address, self._count, self._input_type ) if result is None: - self._available = False + self._attr_available = False self.async_write_ha_state() return - registers = self._swap_registers(result.registers) - byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) - if self._data_type == DATA_TYPE_STRING: - self._value = byte_string.decode() - else: - val = struct.unpack(self._structure, byte_string) - - # Issue: https://github.com/home-assistant/core/issues/41944 - # If unpack() returns a tuple greater than 1, don't try to process the value. - # Instead, return the values of unpack(...) separated by commas. - if len(val) > 1: - # Apply scale and precision to floats and ints - v_result = [] - for entry in val: - v_temp = self._scale * entry + self._offset - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(v_temp, int) and self._precision == 0: - v_result.append(str(v_temp)) - else: - v_result.append(f"{float(v_temp):.{self._precision}f}") - self._value = ",".join(map(str, v_result)) - else: - # Apply scale and precision to floats and ints - val = self._scale * val[0] + self._offset - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(val, int) and self._precision == 0: - self._value = str(val) - else: - self._value = f"{float(val):.{self._precision}f}" - - self._available = True + self.unpack_structure_result(result.registers) + self._attr_available = True self.async_write_ha_state() diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 03f27dd461b..9d72b611adc 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -21,7 +21,18 @@ from .const import ( CONF_SWAP_BYTE, CONF_SWAP_NONE, DATA_TYPE_CUSTOM, - DATA_TYPE_STRING, + DATA_TYPE_FLOAT, + DATA_TYPE_FLOAT16, + DATA_TYPE_FLOAT32, + DATA_TYPE_FLOAT64, + DATA_TYPE_INT, + DATA_TYPE_INT16, + DATA_TYPE_INT32, + DATA_TYPE_INT64, + DATA_TYPE_UINT, + DATA_TYPE_UINT16, + DATA_TYPE_UINT32, + DATA_TYPE_UINT64, DEFAULT_SCAN_INTERVAL, DEFAULT_STRUCT_FORMAT, PLATFORMS, @@ -29,57 +40,78 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +OLD_DATA_TYPES = { + DATA_TYPE_INT: { + 1: DATA_TYPE_INT16, + 2: DATA_TYPE_INT32, + 4: DATA_TYPE_INT64, + }, + DATA_TYPE_UINT: { + 1: DATA_TYPE_UINT16, + 2: DATA_TYPE_UINT32, + 4: DATA_TYPE_UINT64, + }, + DATA_TYPE_FLOAT: { + 1: DATA_TYPE_FLOAT16, + 2: DATA_TYPE_FLOAT32, + 4: DATA_TYPE_FLOAT64, + }, +} -def sensor_schema_validator(config): + +def struct_validator(config): """Sensor schema validator.""" - if config[CONF_DATA_TYPE] == DATA_TYPE_STRING: - structure = str(config[CONF_COUNT] * 2) + "s" - elif config[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: - try: - structure = ( - f">{DEFAULT_STRUCT_FORMAT[config[CONF_DATA_TYPE]][config[CONF_COUNT]]}" - ) - except KeyError as key: - raise vol.Invalid( - f"Unable to detect data type for {config[CONF_NAME]} sensor, try a custom type" - ) from key - else: - structure = config.get(CONF_STRUCTURE) - - if not structure: - raise vol.Invalid( - f"Error in sensor {config[CONF_NAME]}. The `{CONF_STRUCTURE}` field can not be empty " - f"if the parameter `{CONF_DATA_TYPE}` is set to the `{DATA_TYPE_CUSTOM}`" - ) - - try: - size = struct.calcsize(structure) - except struct.error as err: - raise vol.Invalid( - f"Error in sensor {config[CONF_NAME]} structure: {str(err)}" - ) from err - - bytecount = config[CONF_COUNT] * 2 - if bytecount != size: - raise vol.Invalid( - f"Structure request {size} bytes, " - f"but {config[CONF_COUNT]} registers have a size of {bytecount} bytes" - ) - + data_type = config[CONF_DATA_TYPE] + count = config.get(CONF_COUNT, 1) + name = config[CONF_NAME] + structure = config.get(CONF_STRUCTURE) swap_type = config.get(CONF_SWAP) - - if config.get(CONF_SWAP) != CONF_SWAP_NONE: - if swap_type == CONF_SWAP_BYTE: - regs_needed = 1 - else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD - regs_needed = 2 - if config[CONF_COUNT] < regs_needed or (config[CONF_COUNT] % regs_needed) != 0: - raise vol.Invalid( - f"Error in sensor {config[CONF_NAME]} swap({swap_type}) " - f"not possible due to the registers " - f"count: {config[CONF_COUNT]}, needed: {regs_needed}" + if data_type in [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]: + error = f"{name} with {data_type} is not valid, trying to convert" + _LOGGER.warning(error) + try: + data_type = OLD_DATA_TYPES[data_type][config.get(CONF_COUNT, 1)] + except KeyError as exp: + error = f"{name} cannot convert automatically {data_type}" + raise vol.Invalid(error) from exp + if config[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: + if structure: + error = f"{name} structure: cannot be mixed with {data_type}" + raise vol.Invalid(error) + structure = f">{DEFAULT_STRUCT_FORMAT[data_type][0]}" + if CONF_COUNT not in config: + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type][1] + else: + if not structure: + error = ( + f"Error in sensor {name}. The `{CONF_STRUCTURE}` field can not be empty" ) + raise vol.Invalid(error) + try: + size = struct.calcsize(structure) + except struct.error as err: + raise vol.Invalid(f"Error in {name} structure: {str(err)}") from err + + count = config.get(CONF_COUNT, 1) + bytecount = count * 2 + if bytecount != size: + raise vol.Invalid( + f"Structure request {size} bytes, " + f"but {count} registers have a size of {bytecount} bytes" + ) + + if swap_type != CONF_SWAP_NONE: + if swap_type == CONF_SWAP_BYTE: + regs_needed = 1 + else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD + regs_needed = 2 + if count < regs_needed or (count % regs_needed) != 0: + raise vol.Invalid( + f"Error in sensor {name} swap({swap_type}) " + f"not possible due to the registers " + f"count: {count}, needed: {regs_needed}" + ) return { **config, diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 2668b26857b..7c1da064b18 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -8,9 +8,9 @@ import voluptuous as vol from homeassistant.components.fan import SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity from homeassistant.config_entries import ConfigEntry -import homeassistant.helpers.entity_platform as entity_platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -34,7 +34,7 @@ from .const import ( async def async_setup_entry( - hass: HomeAssistantType, + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 2c8298f00da..7431cf0c3bb 100644 --- a/homeassistant/components/modern_forms/light.py +++ b/homeassistant/components/modern_forms/light.py @@ -12,9 +12,9 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.config_entries import ConfigEntry -import homeassistant.helpers.entity_platform as entity_platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.percentage import ( percentage_to_ranged_value, ranged_value_to_percentage, @@ -39,7 +39,7 @@ BRIGHTNESS_RANGE = (1, 255) async def async_setup_entry( - hass: HomeAssistantType, + hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: diff --git a/homeassistant/components/modern_forms/translations/de.json b/homeassistant/components/modern_forms/translations/de.json index 644f525179e..bf16c9532fc 100644 --- a/homeassistant/components/modern_forms/translations/de.json +++ b/homeassistant/components/modern_forms/translations/de.json @@ -10,16 +10,16 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" }, "user": { "data": { "host": "Host" }, - "description": "Einrichten Ihres Modern Forms Ventilator f\u00fcr die Integration in Home Assistant." + "description": "Einrichten deines Modern Forms Ventilator f\u00fcr die Integration in Home Assistant." }, "zeroconf_confirm": { - "description": "M\u00f6chten Sie den Modern Forms Ventilator mit dem Namen `{name}` zu Home Assistant hinzuf\u00fcgen?", + "description": "M\u00f6chtest du den Modern Forms Ventilator mit dem Namen `{name}` zu Home Assistant hinzuf\u00fcgen?", "title": "Erkannter Modern Forms Ventilator" } } diff --git a/homeassistant/components/modern_forms/translations/fr.json b/homeassistant/components/modern_forms/translations/fr.json new file mode 100644 index 00000000000..d68f4a7f680 --- /dev/null +++ b/homeassistant/components/modern_forms/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurer votre ventilateur Modern Forms pour l'int\u00e9grer \u00e0 Home Assistant." + }, + "zeroconf_confirm": { + "description": "Voulez-vous ajouter le fan de Modern Forms nomm\u00e9 ` {name} ` \u00e0 Home Assistant\u00a0?", + "title": "D\u00e9couverte du dispositif de ventilateur Modern Forms" + } + } + }, + "title": "Formes modernes" +} \ No newline at end of file diff --git a/homeassistant/components/modern_forms/translations/hu.json b/homeassistant/components/modern_forms/translations/hu.json index b64bf60763a..fee0216224c 100644 --- a/homeassistant/components/modern_forms/translations/hu.json +++ b/homeassistant/components/modern_forms/translations/hu.json @@ -15,8 +15,14 @@ "user": { "data": { "host": "Hoszt" - } + }, + "description": "\u00c1ll\u00edtsa be a Modern Forms-t, hogy integr\u00e1l\u00f3djon a Home Assistant programba." + }, + "zeroconf_confirm": { + "description": "Hozz\u00e1 szeretn\u00e9 adni a(z) {name} `nev\u0171 Modern Forms rajong\u00f3t a Home Assistanthoz?", + "title": "Felfedezte a Modern Forms rajong\u00f3i eszk\u00f6zt" } } - } + }, + "title": "Modern Forms" } \ No newline at end of file diff --git a/homeassistant/components/monoprice/translations/de.json b/homeassistant/components/monoprice/translations/de.json index 8f6d1d88196..66ae58cf757 100644 --- a/homeassistant/components/monoprice/translations/de.json +++ b/homeassistant/components/monoprice/translations/de.json @@ -18,7 +18,7 @@ "source_5": "Name der Quelle #5", "source_6": "Name der Quelle #6" }, - "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her" + "title": "Verbinden mit dem Ger\u00e4t" } } }, diff --git a/homeassistant/components/motion_blinds/translations/hu.json b/homeassistant/components/motion_blinds/translations/hu.json index 541cefd2110..19f0c70c4d6 100644 --- a/homeassistant/components/motion_blinds/translations/hu.json +++ b/homeassistant/components/motion_blinds/translations/hu.json @@ -5,11 +5,15 @@ "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "connection_error": "Sikertelen csatlakoz\u00e1s" }, + "error": { + "discovery_error": "Nem siker\u00fclt felfedezni a Motion Gateway-t" + }, "step": { "connect": { "data": { "api_key": "API kulcs" - } + }, + "description": "Sz\u00fcks\u00e9ge lesz a 16 karakteres API kulcsra, \u00fatmutat\u00e1s\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key" }, "select": { "data": { @@ -20,7 +24,8 @@ "data": { "api_key": "API kulcs", "host": "IP c\u00edm" - } + }, + "description": "Csatlakozzon a Motion Gateway-hez, ha az IP-c\u00edm nincs be\u00e1ll\u00edtva, akkor az automatikus felder\u00edt\u00e9st haszn\u00e1lja" } } } diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index f766bb86be2..2ade7c48e1b 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -2,44 +2,91 @@ from __future__ import annotations import asyncio +import json import logging +from types import MappingProxyType from typing import Any, Callable +from urllib.parse import urlencode, urljoin +from aiohttp.web import Request, Response from motioneye_client.client import ( MotionEyeClient, MotionEyeClientError, MotionEyeClientInvalidAuthError, ) -from motioneye_client.const import KEY_CAMERAS, KEY_ID, KEY_NAME +from motioneye_client.const import ( + KEY_CAMERAS, + KEY_HTTP_METHOD_POST_JSON, + KEY_ID, + KEY_NAME, + KEY_WEB_HOOK_CONVERSION_SPECIFIERS, + KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, + KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, + KEY_WEB_HOOK_NOTIFICATIONS_URL, + KEY_WEB_HOOK_STORAGE_ENABLED, + KEY_WEB_HOOK_STORAGE_HTTP_METHOD, + KEY_WEB_HOOK_STORAGE_URL, +) from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.webhook import ( + async_generate_id, + async_generate_path, + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL +from homeassistant.const import ( + ATTR_DEVICE_ID, + ATTR_NAME, + CONF_URL, + CONF_WEBHOOK_ID, + HTTP_BAD_REQUEST, +) from homeassistant.core import HomeAssistant, callback 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.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.network import get_url +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( + ATTR_EVENT_TYPE, + ATTR_WEBHOOK_ID, CONF_ADMIN_PASSWORD, CONF_ADMIN_USERNAME, CONF_CLIENT, CONF_COORDINATOR, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, + CONF_WEBHOOK_SET, + CONF_WEBHOOK_SET_OVERWRITE, DEFAULT_SCAN_INTERVAL, + DEFAULT_WEBHOOK_SET, + DEFAULT_WEBHOOK_SET_OVERWRITE, DOMAIN, + EVENT_FILE_STORED, + EVENT_FILE_STORED_KEYS, + EVENT_MOTION_DETECTED, + EVENT_MOTION_DETECTED_KEYS, MOTIONEYE_MANUFACTURER, SIGNAL_CAMERA_ADD, + WEB_HOOK_SENTINEL_KEY, + WEB_HOOK_SENTINEL_VALUE, ) _LOGGER = logging.getLogger(__name__) - -PLATFORMS = [CAMERA_DOMAIN] +PLATFORMS = [CAMERA_DOMAIN, SWITCH_DOMAIN] def create_motioneye_client( @@ -97,6 +144,15 @@ def listen_for_new_cameras( ) +@callback +def async_generate_motioneye_webhook(hass: HomeAssistant, webhook_id: str) -> str: + """Generate the full local URL for a webhook_id.""" + return "{}{}".format( + get_url(hass, allow_cloud=False), + async_generate_path(webhook_id), + ) + + @callback def _add_camera( hass: HomeAssistant, @@ -109,13 +165,93 @@ def _add_camera( ) -> None: """Add a motionEye camera to hass.""" - device_registry.async_get_or_create( + def _is_recognized_web_hook(url: str) -> bool: + """Determine whether this integration set a web hook.""" + return f"{WEB_HOOK_SENTINEL_KEY}={WEB_HOOK_SENTINEL_VALUE}" in url + + def _set_webhook( + url: str, + key_url: str, + key_method: str, + key_enabled: str, + camera: dict[str, Any], + ) -> bool: + """Set a web hook.""" + if ( + entry.options.get( + CONF_WEBHOOK_SET_OVERWRITE, + DEFAULT_WEBHOOK_SET_OVERWRITE, + ) + or not camera.get(key_url) + or _is_recognized_web_hook(camera[key_url]) + ) and ( + not camera.get(key_enabled, False) + or camera.get(key_method) != KEY_HTTP_METHOD_POST_JSON + or camera.get(key_url) != url + ): + camera[key_enabled] = True + camera[key_method] = KEY_HTTP_METHOD_POST_JSON + camera[key_url] = url + return True + return False + + def _build_url( + device: dr.DeviceEntry, base: str, event_type: str, keys: list[str] + ) -> str: + """Build a motionEye webhook URL.""" + + # This URL-surgery cannot use YARL because the output must NOT be + # url-encoded. This is because motionEye will do further string + # manipulation/substitution on this value before ultimately fetching it, + # and it cannot deal with URL-encoded input to that string manipulation. + return urljoin( + base, + "?" + + urlencode( + { + **{k: KEY_WEB_HOOK_CONVERSION_SPECIFIERS[k] for k in sorted(keys)}, + WEB_HOOK_SENTINEL_KEY: WEB_HOOK_SENTINEL_VALUE, + ATTR_EVENT_TYPE: event_type, + ATTR_DEVICE_ID: device.id, + }, + safe="%{}", + ), + ) + + device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={device_identifier}, manufacturer=MOTIONEYE_MANUFACTURER, model=MOTIONEYE_MANUFACTURER, name=camera[KEY_NAME], ) + if entry.options.get(CONF_WEBHOOK_SET, DEFAULT_WEBHOOK_SET): + url = async_generate_motioneye_webhook(hass, entry.data[CONF_WEBHOOK_ID]) + + if _set_webhook( + _build_url( + device, + url, + EVENT_MOTION_DETECTED, + EVENT_MOTION_DETECTED_KEYS, + ), + KEY_WEB_HOOK_NOTIFICATIONS_URL, + KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, + KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, + camera, + ) | _set_webhook( + _build_url( + device, + url, + EVENT_FILE_STORED, + EVENT_FILE_STORED_KEYS, + ), + KEY_WEB_HOOK_STORAGE_URL, + KEY_WEB_HOOK_STORAGE_HTTP_METHOD, + KEY_WEB_HOOK_STORAGE_ENABLED, + camera, + ): + hass.async_create_task(client.async_set_camera(camera_id, camera)) async_dispatcher_send( hass, @@ -124,6 +260,11 @@ def _add_camera( ) +async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Handle entry updates.""" + await hass.config_entries.async_reload(config_entry.entry_id) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up motionEye from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -134,6 +275,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: admin_password=entry.data.get(CONF_ADMIN_PASSWORD), surveillance_username=entry.data.get(CONF_SURVEILLANCE_USERNAME), surveillance_password=entry.data.get(CONF_SURVEILLANCE_PASSWORD), + session=async_get_clientsession(hass), ) try: @@ -145,6 +287,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await client.async_client_close() raise ConfigEntryNotReady from exc + # Ensure every loaded entry has a registered webhook id. + if CONF_WEBHOOK_ID not in entry.data: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_WEBHOOK_ID: async_generate_id()} + ) + webhook_register( + hass, DOMAIN, "motionEye", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + @callback async def async_update_data() -> dict[str, Any] | None: try: @@ -196,8 +347,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_identifier, ) - # Ensure every device associated with this config entry is still in the list of - # motionEye cameras, otherwise remove the device (and thus entities). + # Ensure every device associated with this config entry is still in the + # list of motionEye cameras, otherwise remove the device (and thus + # entities). for device_entry in dr.async_entries_for_config_entry( device_registry, entry.entry_id ): @@ -209,15 +361,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def setup_then_listen() -> None: await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) entry.async_on_unload( coordinator.async_add_listener(_async_process_motioneye_cameras) ) await coordinator.async_refresh() + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) hass.async_create_task(setup_then_listen()) return True @@ -225,9 +378,95 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: config_data = hass.data[DOMAIN].pop(entry.entry_id) await config_data[CONF_CLIENT].async_client_close() return unload_ok + + +async def handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> None | Response: + """Handle webhook callback.""" + + try: + data = await request.json() + except (json.decoder.JSONDecodeError, UnicodeDecodeError): + return Response( + text="Could not decode request", + status=HTTP_BAD_REQUEST, + ) + + for key in (ATTR_DEVICE_ID, ATTR_EVENT_TYPE): + if key not in data: + return Response( + text=f"Missing webhook parameter: {key}", + status=HTTP_BAD_REQUEST, + ) + + event_type = data[ATTR_EVENT_TYPE] + device_registry = dr.async_get(hass) + device_id = data[ATTR_DEVICE_ID] + device = device_registry.async_get(device_id) + + if not device: + return Response( + text=f"Device not found: {device_id}", + status=HTTP_BAD_REQUEST, + ) + + hass.bus.async_fire( + f"{DOMAIN}.{event_type}", + { + ATTR_DEVICE_ID: device.id, + ATTR_NAME: device.name, + ATTR_WEBHOOK_ID: webhook_id, + **data, + }, + ) + return None + + +class MotionEyeEntity(CoordinatorEntity): + """Base class for motionEye entities.""" + + def __init__( + self, + config_entry_id: str, + type_name: str, + camera: dict[str, Any], + client: MotionEyeClient, + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, Any], + entity_description: EntityDescription = None, + ) -> None: + """Initialize a motionEye entity.""" + self._camera_id = camera[KEY_ID] + self._device_identifier = get_motioneye_device_identifier( + config_entry_id, self._camera_id + ) + self._unique_id = get_motioneye_entity_unique_id( + config_entry_id, + self._camera_id, + type_name, + ) + self._client = client + self._camera: dict[str, Any] | None = camera + self._options = options + if entity_description is not None: + self.entity_description = entity_description + super().__init__(coordinator) + + @property + def unique_id(self) -> str: + """Return a unique id for this instance.""" + return self._unique_id + + @property + def device_info(self) -> DeviceInfo: + """Return the device information.""" + return {"identifiers": {self._device_identifier}} diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index e3cad73dfc5..0727646b64d 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -2,13 +2,13 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional +from types import MappingProxyType +from typing import Any import aiohttp from motioneye_client.client import MotionEyeClient from motioneye_client.const import ( DEFAULT_SURVEILLANCE_USERNAME, - KEY_ID, KEY_MOTION_DETECTION, KEY_NAME, KEY_STREAMING_AUTH_MODE, @@ -30,17 +30,12 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import ( + MotionEyeEntity, get_camera_from_cameras, - get_motioneye_device_identifier, - get_motioneye_entity_unique_id, is_acceptable_camera, listen_for_new_cameras, ) @@ -79,6 +74,7 @@ async def async_setup_entry( camera, entry_data[CONF_CLIENT], entry_data[CONF_COORDINATOR], + entry.options, ) ] ) @@ -86,7 +82,7 @@ async def async_setup_entry( listen_for_new_cameras(hass, entry, camera_add) -class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity[Optional[Dict[str, Any]]]): +class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" def __init__( @@ -96,25 +92,26 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity[Optional[Dict[str, Any password: str, camera: dict[str, Any], client: MotionEyeClient, - coordinator: DataUpdateCoordinator[dict[str, Any] | None], + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, str], ) -> None: """Initialize a MJPEG camera.""" self._surveillance_username = username self._surveillance_password = password - self._client = client - self._camera_id = camera[KEY_ID] - self._device_identifier = get_motioneye_device_identifier( - config_entry_id, self._camera_id - ) - self._unique_id = get_motioneye_entity_unique_id( - config_entry_id, self._camera_id, TYPE_MOTIONEYE_MJPEG_CAMERA - ) self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) - self._available = self._is_acceptable_streaming_camera(camera) # motionEye cameras are always streaming or unavailable. self.is_streaming = True + MotionEyeEntity.__init__( + self, + config_entry_id, + TYPE_MOTIONEYE_MJPEG_CAMERA, + camera, + client, + coordinator, + options, + ) MjpegCamera.__init__( self, { @@ -122,7 +119,6 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity[Optional[Dict[str, Any **self._get_mjpeg_camera_properties_for_camera(camera), }, ) - CoordinatorEntity.__init__(self, coordinator) @callback def _get_mjpeg_camera_properties_for_camera( @@ -162,35 +158,26 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity[Optional[Dict[str, Any if self._authentication == HTTP_BASIC_AUTHENTICATION: self._auth = aiohttp.BasicAuth(self._username, password=self._password) - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id - - @classmethod - def _is_acceptable_streaming_camera(cls, camera: dict[str, Any] | None) -> bool: + def _is_acceptable_streaming_camera(self) -> bool: """Determine if a camera is streaming/usable.""" - return is_acceptable_camera(camera) and MotionEyeClient.is_camera_streaming( - camera - ) + return is_acceptable_camera( + self._camera + ) and MotionEyeClient.is_camera_streaming(self._camera) @property def available(self) -> bool: """Return if entity is available.""" - return self._available + return super().available and self._is_acceptable_streaming_camera() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - available = False - if self.coordinator.last_update_success: - camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) - if self._is_acceptable_streaming_camera(camera): - assert camera - self._set_mjpeg_camera_state_for_camera(camera) - self._motion_detection_enabled = camera.get(KEY_MOTION_DETECTION, False) - available = True - self._available = available + self._camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) + if self._camera and self._is_acceptable_streaming_camera(): + self._set_mjpeg_camera_state_for_camera(self._camera) + self._motion_detection_enabled = self._camera.get( + KEY_MOTION_DETECTION, False + ) super()._handle_coordinator_update() @property @@ -202,8 +189,3 @@ class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity[Optional[Dict[str, Any def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return self._motion_detection_enabled - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return {"identifiers": {self._device_identifier}} diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 463c804028a..a5f92a3ce09 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -11,10 +11,17 @@ from motioneye_client.client import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow -from homeassistant.const import CONF_SOURCE, CONF_URL +from homeassistant.config_entries import ( + SOURCE_REAUTH, + ConfigEntry, + ConfigFlow, + OptionsFlow, +) +from homeassistant.const import CONF_SOURCE, CONF_URL, CONF_WEBHOOK_ID +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import create_motioneye_client from .const import ( @@ -22,6 +29,10 @@ from .const import ( CONF_ADMIN_USERNAME, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, + CONF_WEBHOOK_SET, + CONF_WEBHOOK_SET_OVERWRITE, + DEFAULT_WEBHOOK_SET, + DEFAULT_WEBHOOK_SET_OVERWRITE, DOMAIN, ) @@ -104,6 +115,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): admin_password=user_input.get(CONF_ADMIN_PASSWORD), surveillance_username=user_input.get(CONF_SURVEILLANCE_USERNAME), surveillance_password=user_input.get(CONF_SURVEILLANCE_PASSWORD), + session=async_get_clientsession(self.hass), ) errors = {} @@ -122,6 +134,9 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): return _get_form(user_input, errors) if self.context.get(CONF_SOURCE) == SOURCE_REAUTH and reauth_entry is not None: + # Persist the same webhook id across reauths. + if CONF_WEBHOOK_ID in reauth_entry.data: + user_input[CONF_WEBHOOK_ID] = reauth_entry.data[CONF_WEBHOOK_ID] self.hass.config_entries.async_update_entry(reauth_entry, data=user_input) # Need to manually reload, as the listener won't have been # installed because the initial load did not succeed (the reauth @@ -167,3 +182,43 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): ) return await self.async_step_user() + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> MotionEyeOptionsFlow: + """Get the Hyperion Options flow.""" + return MotionEyeOptionsFlow(config_entry) + + +class MotionEyeOptionsFlow(OptionsFlow): + """motionEye options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize a motionEye options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + schema: dict[vol.Marker, type] = { + vol.Required( + CONF_WEBHOOK_SET, + default=self._config_entry.options.get( + CONF_WEBHOOK_SET, + DEFAULT_WEBHOOK_SET, + ), + ): bool, + vol.Required( + CONF_WEBHOOK_SET_OVERWRITE, + default=self._config_entry.options.get( + CONF_WEBHOOK_SET_OVERWRITE, + DEFAULT_WEBHOOK_SET_OVERWRITE, + ), + ): bool, + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) diff --git a/homeassistant/components/motioneye/const.py b/homeassistant/components/motioneye/const.py index fbd0d9b4d2e..41fb2c18d63 100644 --- a/homeassistant/components/motioneye/const.py +++ b/homeassistant/components/motioneye/const.py @@ -1,19 +1,90 @@ """Constants for the motionEye integration.""" from datetime import timedelta +from typing import Final -DOMAIN = "motioneye" +from motioneye_client.const import ( + KEY_WEB_HOOK_CS_CAMERA_ID, + KEY_WEB_HOOK_CS_CHANGED_PIXELS, + KEY_WEB_HOOK_CS_DESPECKLE_LABELS, + KEY_WEB_HOOK_CS_EVENT, + KEY_WEB_HOOK_CS_FILE_PATH, + KEY_WEB_HOOK_CS_FILE_TYPE, + KEY_WEB_HOOK_CS_FPS, + KEY_WEB_HOOK_CS_FRAME_NUMBER, + KEY_WEB_HOOK_CS_HEIGHT, + KEY_WEB_HOOK_CS_HOST, + KEY_WEB_HOOK_CS_MOTION_CENTER_X, + KEY_WEB_HOOK_CS_MOTION_CENTER_Y, + KEY_WEB_HOOK_CS_MOTION_HEIGHT, + KEY_WEB_HOOK_CS_MOTION_VERSION, + KEY_WEB_HOOK_CS_MOTION_WIDTH, + KEY_WEB_HOOK_CS_NOISE_LEVEL, + KEY_WEB_HOOK_CS_THRESHOLD, + KEY_WEB_HOOK_CS_WIDTH, +) -CONF_CLIENT = "client" -CONF_COORDINATOR = "coordinator" -CONF_ADMIN_PASSWORD = "admin_password" -CONF_ADMIN_USERNAME = "admin_username" -CONF_SURVEILLANCE_USERNAME = "surveillance_username" -CONF_SURVEILLANCE_PASSWORD = "surveillance_password" -DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) +DOMAIN: Final = "motioneye" -MOTIONEYE_MANUFACTURER = "motionEye" +ATTR_EVENT_TYPE: Final = "event_type" +ATTR_WEBHOOK_ID: Final = "webhook_id" -SIGNAL_CAMERA_ADD = f"{DOMAIN}_camera_add_signal." "{}" -SIGNAL_CAMERA_REMOVE = f"{DOMAIN}_camera_remove_signal." "{}" +CONF_CLIENT: Final = "client" +CONF_COORDINATOR: Final = "coordinator" +CONF_ADMIN_PASSWORD: Final = "admin_password" +CONF_ADMIN_USERNAME: Final = "admin_username" +CONF_SURVEILLANCE_USERNAME: Final = "surveillance_username" +CONF_SURVEILLANCE_PASSWORD: Final = "surveillance_password" +CONF_WEBHOOK_SET: Final = "webhook_set" +CONF_WEBHOOK_SET_OVERWRITE: Final = "webhook_set_overwrite" -TYPE_MOTIONEYE_MJPEG_CAMERA = "motioneye_mjpeg_camera" +DEFAULT_WEBHOOK_SET: Final = True +DEFAULT_WEBHOOK_SET_OVERWRITE: Final = False +DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=30) + +EVENT_MOTION_DETECTED: Final = "motion_detected" +EVENT_FILE_STORED: Final = "file_stored" + +EVENT_MOTION_DETECTED_KEYS: Final = [ + KEY_WEB_HOOK_CS_EVENT, + KEY_WEB_HOOK_CS_FRAME_NUMBER, + KEY_WEB_HOOK_CS_CAMERA_ID, + KEY_WEB_HOOK_CS_CHANGED_PIXELS, + KEY_WEB_HOOK_CS_NOISE_LEVEL, + KEY_WEB_HOOK_CS_WIDTH, + KEY_WEB_HOOK_CS_HEIGHT, + KEY_WEB_HOOK_CS_MOTION_WIDTH, + KEY_WEB_HOOK_CS_MOTION_HEIGHT, + KEY_WEB_HOOK_CS_MOTION_CENTER_X, + KEY_WEB_HOOK_CS_MOTION_CENTER_Y, + KEY_WEB_HOOK_CS_THRESHOLD, + KEY_WEB_HOOK_CS_DESPECKLE_LABELS, + KEY_WEB_HOOK_CS_FPS, + KEY_WEB_HOOK_CS_HOST, + KEY_WEB_HOOK_CS_MOTION_VERSION, +] + +EVENT_FILE_STORED_KEYS: Final = [ + KEY_WEB_HOOK_CS_EVENT, + KEY_WEB_HOOK_CS_FRAME_NUMBER, + KEY_WEB_HOOK_CS_CAMERA_ID, + KEY_WEB_HOOK_CS_NOISE_LEVEL, + KEY_WEB_HOOK_CS_WIDTH, + KEY_WEB_HOOK_CS_HEIGHT, + KEY_WEB_HOOK_CS_FILE_PATH, + KEY_WEB_HOOK_CS_FILE_TYPE, + KEY_WEB_HOOK_CS_THRESHOLD, + KEY_WEB_HOOK_CS_FPS, + KEY_WEB_HOOK_CS_HOST, + KEY_WEB_HOOK_CS_MOTION_VERSION, +] + +MOTIONEYE_MANUFACTURER: Final = "motionEye" + +SIGNAL_CAMERA_ADD: Final = f"{DOMAIN}_camera_add_signal." "{}" +SIGNAL_CAMERA_REMOVE: Final = f"{DOMAIN}_camera_remove_signal." "{}" + +TYPE_MOTIONEYE_MJPEG_CAMERA: Final = "motioneye_mjpeg_camera" +TYPE_MOTIONEYE_SWITCH_BASE: Final = f"{DOMAIN}_switch" + +WEB_HOOK_SENTINEL_KEY: Final = "src" +WEB_HOOK_SENTINEL_VALUE: Final = "hass-motioneye" diff --git a/homeassistant/components/motioneye/manifest.json b/homeassistant/components/motioneye/manifest.json index 43cb231c30c..9be95c21162 100644 --- a/homeassistant/components/motioneye/manifest.json +++ b/homeassistant/components/motioneye/manifest.json @@ -3,11 +3,15 @@ "name": "motionEye", "documentation": "https://www.home-assistant.io/integrations/motioneye", "config_flow": true, + "dependencies": [ + "http", + "webhook" + ], "requirements": [ - "motioneye-client==0.3.6" + "motioneye-client==0.3.11" ], "codeowners": [ "@dermotduffy" ], "iot_class": "local_polling" -} \ No newline at end of file +} diff --git a/homeassistant/components/motioneye/strings.json b/homeassistant/components/motioneye/strings.json index d89b5cab275..9763e1caf34 100644 --- a/homeassistant/components/motioneye/strings.json +++ b/homeassistant/components/motioneye/strings.json @@ -25,5 +25,15 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", + "webhook_set_overwrite": "Overwrite unrecognized webhooks" + } + } + } } } diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py new file mode 100644 index 00000000000..f9197d00c08 --- /dev/null +++ b/homeassistant/components/motioneye/switch.py @@ -0,0 +1,140 @@ +"""Switch platform for motionEye.""" +from __future__ import annotations + +from types import MappingProxyType +from typing import Any + +from motioneye_client.client import MotionEyeClient +from motioneye_client.const import ( + KEY_MOTION_DETECTION, + KEY_MOVIES, + KEY_NAME, + KEY_STILL_IMAGES, + KEY_TEXT_OVERLAY, + KEY_UPLOAD_ENABLED, + KEY_VIDEO_STREAMING, +) + +from homeassistant.components.switch import SwitchEntity +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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import MotionEyeEntity, get_camera_from_cameras, listen_for_new_cameras +from .const import CONF_CLIENT, CONF_COORDINATOR, DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE + +MOTIONEYE_SWITCHES = [ + EntityDescription( + key=KEY_MOTION_DETECTION, + name="Motion Detection", + entity_registry_enabled_default=True, + ), + EntityDescription( + key=KEY_TEXT_OVERLAY, name="Text Overlay", entity_registry_enabled_default=False + ), + EntityDescription( + key=KEY_VIDEO_STREAMING, + name="Video Streaming", + entity_registry_enabled_default=False, + ), + EntityDescription( + key=KEY_STILL_IMAGES, name="Still Images", entity_registry_enabled_default=True + ), + EntityDescription( + key=KEY_MOVIES, name="Movies", entity_registry_enabled_default=True + ), + EntityDescription( + key=KEY_UPLOAD_ENABLED, + name="Upload Enabled", + entity_registry_enabled_default=False, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up motionEye from a config entry.""" + entry_data = hass.data[DOMAIN][entry.entry_id] + + @callback + def camera_add(camera: dict[str, Any]) -> None: + """Add a new motionEye camera.""" + async_add_entities( + [ + MotionEyeSwitch( + entry.entry_id, + camera, + entry_data[CONF_CLIENT], + entry_data[CONF_COORDINATOR], + entry.options, + entity_description, + ) + for entity_description in MOTIONEYE_SWITCHES + ] + ) + + listen_for_new_cameras(hass, entry, camera_add) + + +class MotionEyeSwitch(MotionEyeEntity, SwitchEntity): + """MotionEyeSwitch switch class.""" + + def __init__( + self, + config_entry_id: str, + camera: dict[str, Any], + client: MotionEyeClient, + coordinator: DataUpdateCoordinator, + options: MappingProxyType[str, str], + entity_description: EntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__( + config_entry_id, + f"{TYPE_MOTIONEYE_SWITCH_BASE}_{entity_description.key}", + camera, + client, + coordinator, + options, + entity_description, + ) + + @property + def name(self) -> str: + """Return the name of the switch.""" + camera_prepend = f"{self._camera[KEY_NAME]} " if self._camera else "" + return f"{camera_prepend}{self.entity_description.name}" + + @property + def is_on(self) -> bool: + """Return true if the switch is on.""" + return bool( + self._camera and self._camera.get(self.entity_description.key, False) + ) + + async def _async_send_set_camera(self, value: bool) -> None: + """Set a switch value.""" + + # Fetch the very latest camera config to reduce the risk of updating with a + # stale configuration. + camera = await self._client.async_get_camera(self._camera_id) + if camera: + camera[self.entity_description.key] = value + await self._client.async_set_camera(self._camera_id, camera) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self._async_send_set_camera(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self._async_send_set_camera(False) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._camera = get_camera_from_cameras(self._camera_id, self.coordinator.data) + super()._handle_coordinator_update() diff --git a/homeassistant/components/motioneye/translations/ar.json b/homeassistant/components/motioneye/translations/ar.json new file mode 100644 index 00000000000..c4e4b0f397a --- /dev/null +++ b/homeassistant/components/motioneye/translations/ar.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "init": { + "data": { + "webhook_set": "\u0642\u0645 \u0628\u062a\u0643\u0648\u064a\u0646 webhooks \u0627\u0644\u062e\u0627\u0635\u0629 \u0628\u0640 motionEye \u0644\u0644\u0625\u0628\u0644\u0627\u063a \u0639\u0646 \u0627\u0644\u0623\u062d\u062f\u0627\u062b \u0644\u0640 Home Assistant", + "webhook_set_overwrite": "\u0627\u0644\u0643\u062a\u0627\u0628\u0629 \u0641\u0648\u0642 webhooks \u063a\u064a\u0631 \u0645\u0639\u0631\u0648\u0641" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/ca.json b/homeassistant/components/motioneye/translations/ca.json index 8f11dba2802..85477627f38 100644 --- a/homeassistant/components/motioneye/translations/ca.json +++ b/homeassistant/components/motioneye/translations/ca.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configura els webhooks de motionEye per enviar esdeveniments a Home Assistant", + "webhook_set_overwrite": "Sobreescriu els webhooks no reconeguts" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/de.json b/homeassistant/components/motioneye/translations/de.json index 3370717366d..d565329b7cd 100644 --- a/homeassistant/components/motioneye/translations/de.json +++ b/homeassistant/components/motioneye/translations/de.json @@ -12,7 +12,7 @@ }, "step": { "hassio_confirm": { - "description": "M\u00f6chten Sie Home Assistant so konfigurieren, dass er sich mit dem motionEye-Dienst des Add-ons {addon} verbindet?", + "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er sich mit dem motionEye-Dienst des Add-ons {addon} verbindet?", "title": "motionEye \u00fcber Home Assistant Add-on" }, "user": { @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "MotionEye-Webhooks konfigurieren, um Ereignisse an Home Assistant zu melden", + "webhook_set_overwrite": "\u00dcberschreiben von nicht bekannten Webhooks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/en.json b/homeassistant/components/motioneye/translations/en.json index b93e4f66894..6c24b7850d4 100644 --- a/homeassistant/components/motioneye/translations/en.json +++ b/homeassistant/components/motioneye/translations/en.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configure motionEye webhooks to report events to Home Assistant", + "webhook_set_overwrite": "Overwrite unrecognized webhooks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/es.json b/homeassistant/components/motioneye/translations/es.json index d018c52515e..b24502caffd 100644 --- a/homeassistant/components/motioneye/translations/es.json +++ b/homeassistant/components/motioneye/translations/es.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configure los webhooks de motionEye para informar eventos a Home Assistant", + "webhook_set_overwrite": "Sobrescribir webhooks no reconocidos" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/et.json b/homeassistant/components/motioneye/translations/et.json index 1b0861dbc75..b3e3919123c 100644 --- a/homeassistant/components/motioneye/translations/et.json +++ b/homeassistant/components/motioneye/translations/et.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Seadista motionEye veebihaagid, et teatada s\u00fcndmustest Home Assistanti'le", + "webhook_set_overwrite": "Kirjuta tundmatud veebihaagid \u00fcle" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/fr.json b/homeassistant/components/motioneye/translations/fr.json index a520c05dba2..b8d79b683a6 100644 --- a/homeassistant/components/motioneye/translations/fr.json +++ b/homeassistant/components/motioneye/translations/fr.json @@ -1,18 +1,37 @@ { "config": { "abort": { - "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { - "invalid_url": "URL invalide" + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "invalid_url": "URL invalide", + "unknown": "Erreur inattendue" }, "step": { + "hassio_confirm": { + "description": "Voulez-vous configurer Home Assistant pour vous connecter au service motionEye fourni par le module compl\u00e9mentaire\u00a0: {addon}\u00a0?", + "title": "motionEye via le module compl\u00e9mentaire Home Assistant" + }, "user": { "data": { "admin_password": "Admin Mot de passe", "admin_username": "Admin Nom d'utilisateur", "surveillance_password": "Surveillance Mot de passe", - "surveillance_username": "Surveillance Nom d'utilisateur" + "surveillance_username": "Surveillance Nom d'utilisateur", + "url": "URL" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configurer les webhooks motionEye pour signaler les \u00e9v\u00e9nements \u00e0 Home Assistant", + "webhook_set_overwrite": "\u00c9craser les webhooks non reconnus" } } } diff --git a/homeassistant/components/motioneye/translations/hu.json b/homeassistant/components/motioneye/translations/hu.json new file mode 100644 index 00000000000..5b23c74dc76 --- /dev/null +++ b/homeassistant/components/motioneye/translations/hu.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_url": "\u00c9rv\u00e9nytelen URL", + "unknown": "Ismeretlen hiba" + }, + "step": { + "hassio_confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Home Assistant, hogy csatlakozzon a(z) {addon} \u00e1ltal biztos\u00edtott motionEye szolg\u00e1ltat\u00e1shoz?", + "title": "motionEye a Home Assistant kieg\u00e9sz\u00edt\u0151 seg\u00edts\u00e9g\u00e9vel" + }, + "user": { + "data": { + "admin_password": "Rendszergazda Jelsz\u00f3", + "admin_username": "Rendszergazda Felhaszn\u00e1l\u00f3n\u00e9v", + "surveillance_password": "Fel\u00fcgyelet Jelsz\u00f3", + "surveillance_username": "Fel\u00fcgyelet Felhaszn\u00e1l\u00f3n\u00e9v", + "url": "URL" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "\u00c1ll\u00edtsa be a motionEye webhookokat az esem\u00e9nyek jelent\u00e9s\u00e9nek a Home Assistant sz\u00e1m\u00e1ra", + "webhook_set_overwrite": "Fel\u00fcl\u00edrja a fel nem ismert webhookokat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/id.json b/homeassistant/components/motioneye/translations/id.json new file mode 100644 index 00000000000..0278ac26195 --- /dev/null +++ b/homeassistant/components/motioneye/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "invalid_url": "URL tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "admin_password": "Kata Sandi Admin", + "admin_username": "Nama Pengguna Admin", + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/it.json b/homeassistant/components/motioneye/translations/it.json index 27e1167b3db..77307be07dd 100644 --- a/homeassistant/components/motioneye/translations/it.json +++ b/homeassistant/components/motioneye/translations/it.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Configura i webhooks di motionEye per segnalare gli eventi a Home Assistant", + "webhook_set_overwrite": "Sovrascrivi webhook non riconosciuti" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/nl.json b/homeassistant/components/motioneye/translations/nl.json index 0fd3c7661eb..81bb0365c33 100644 --- a/homeassistant/components/motioneye/translations/nl.json +++ b/homeassistant/components/motioneye/translations/nl.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "MotionEye-webhooks configureren om gebeurtenissen aan Home Assistant te melden", + "webhook_set_overwrite": "Overschrijf niet-herkende webhooks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/no.json b/homeassistant/components/motioneye/translations/no.json index 33dadffa94f..c0fd5e881c2 100644 --- a/homeassistant/components/motioneye/translations/no.json +++ b/homeassistant/components/motioneye/translations/no.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Konfigurer motionEye webhooks for \u00e5 rapportere hendelser til Home Assistant", + "webhook_set_overwrite": "Overskriv ukjente webhooks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/pl.json b/homeassistant/components/motioneye/translations/pl.json index 296c2f963af..7267a70b677 100644 --- a/homeassistant/components/motioneye/translations/pl.json +++ b/homeassistant/components/motioneye/translations/pl.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "Skonfiguruj webhook motionEye, aby zg\u0142asza\u0107 zdarzenia do Home Assistanta", + "webhook_set_overwrite": "Nadpisz nierozpoznane webhooki" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/ru.json b/homeassistant/components/motioneye/translations/ru.json index 8999b0e8f82..fbda6e7abdc 100644 --- a/homeassistant/components/motioneye/translations/ru.json +++ b/homeassistant/components/motioneye/translations/ru.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Webhook motionEye \u0434\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u043e\u0442\u0447\u0435\u0442\u043e\u0432 \u043e \u0441\u043e\u0431\u044b\u0442\u0438\u044f\u0445 \u0432 Home Assistant", + "webhook_set_overwrite": "\u041f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u043d\u0435\u0440\u0430\u0441\u043f\u043e\u0437\u043d\u0430\u043d\u043d\u044b\u0435 Webhook" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/zh-Hant.json b/homeassistant/components/motioneye/translations/zh-Hant.json index 8c143655f2f..a443ee6954b 100644 --- a/homeassistant/components/motioneye/translations/zh-Hant.json +++ b/homeassistant/components/motioneye/translations/zh-Hant.json @@ -25,5 +25,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "webhook_set": "\u8a2d\u5b9a motionEye webhooks \u4ee5\u56de\u5831\u4e8b\u4ef6\u81f3 Home Assistant", + "webhook_set_overwrite": "\u8986\u84cb\u7121\u6cd5\u8fa8\u8b58\u7684 Webhooks" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3a6fd068975..ec5f5f6d1af 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -9,7 +9,7 @@ import logging from operator import attrgetter import ssl import time -from typing import Any, Callable, Union +from typing import Any, Awaitable, Callable, Union, cast import uuid import attr @@ -73,7 +73,14 @@ from .const import ( PROTOCOL_311, ) from .discovery import LAST_DISCOVERY -from .models import Message, MessageCallbackType, PublishPayloadType +from .models import ( + AsyncMessageCallbackType, + MessageCallbackType, + PublishMessage, + PublishPayloadType, + ReceiveMessage, + ReceivePayloadType, +) from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -119,6 +126,7 @@ PLATFORMS = [ "climate", "cover", "fan", + "humidifier", "light", "lock", "number", @@ -284,26 +292,36 @@ def async_publish_template( hass.async_create_task(hass.services.async_call(DOMAIN, SERVICE_PUBLISH, data)) -def wrap_msg_callback(msg_callback: MessageCallbackType) -> MessageCallbackType: +AsyncDeprecatedMessageCallbackType = Callable[ + [str, ReceivePayloadType, int], Awaitable[None] +] +DeprecatedMessageCallbackType = Callable[[str, ReceivePayloadType, int], None] + + +def wrap_msg_callback( + msg_callback: AsyncDeprecatedMessageCallbackType | DeprecatedMessageCallbackType, +) -> AsyncMessageCallbackType | MessageCallbackType: """Wrap an MQTT message callback to support deprecated signature.""" # Check for partials to properly determine if coroutine function check_func = msg_callback while isinstance(check_func, partial): check_func = check_func.func - wrapper_func = None + wrapper_func: AsyncMessageCallbackType | MessageCallbackType if asyncio.iscoroutinefunction(check_func): @wraps(msg_callback) - async def async_wrapper(msg: Any) -> None: + async def async_wrapper(msg: ReceiveMessage) -> None: """Call with deprecated signature.""" - await msg_callback(msg.topic, msg.payload, msg.qos) + await cast(AsyncDeprecatedMessageCallbackType, msg_callback)( + msg.topic, msg.payload, msg.qos + ) wrapper_func = async_wrapper else: @wraps(msg_callback) - def wrapper(msg: Any) -> None: + def wrapper(msg: ReceiveMessage) -> None: """Call with deprecated signature.""" msg_callback(msg.topic, msg.payload, msg.qos) @@ -315,7 +333,10 @@ def wrap_msg_callback(msg_callback: MessageCallbackType) -> MessageCallbackType: async def async_subscribe( hass: HomeAssistant, topic: str, - msg_callback: MessageCallbackType, + msg_callback: AsyncMessageCallbackType + | MessageCallbackType + | DeprecatedMessageCallbackType + | AsyncDeprecatedMessageCallbackType, qos: int = DEFAULT_QOS, encoding: str | None = "utf-8", ): @@ -334,12 +355,15 @@ async def async_subscribe( wrapped_msg_callback = msg_callback # If we have 3 parameters with no default value, wrap the callback if non_default == 3: + module = inspect.getmodule(msg_callback) _LOGGER.warning( "Signature of MQTT msg_callback '%s.%s' is deprecated", - inspect.getmodule(msg_callback).__name__, + module.__name__ if module else "", msg_callback.__name__, ) - wrapped_msg_callback = wrap_msg_callback(msg_callback) + wrapped_msg_callback = wrap_msg_callback( + cast(DeprecatedMessageCallbackType, msg_callback) + ) async_remove = await hass.data[DATA_MQTT].async_subscribe( topic, @@ -378,16 +402,12 @@ def subscribe( async def _async_setup_discovery( hass: HomeAssistant, conf: ConfigType, config_entry -) -> bool: +) -> None: """Try to start the discovery of MQTT devices. This method is a coroutine. """ - success: bool = await discovery.async_start( - hass, conf[CONF_DISCOVERY_PREFIX], config_entry - ) - - return success + await discovery.async_start(hass, conf[CONF_DISCOVERY_PREFIX], config_entry) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -502,7 +522,7 @@ async def async_setup_entry(hass, entry): unsub = await async_subscribe(hass, call.data["topic"], collect_msg) def write_dump(): - with open(hass.config.path("mqtt_dump.txt"), "wt") as fp: + with open(hass.config.path("mqtt_dump.txt"), "wt", encoding="utf8") as fp: for msg in messages: fp.write(",".join(msg) + "\n") @@ -539,7 +559,7 @@ class Subscription: matcher: Any = attr.ib() job: HassJob = attr.ib() qos: int = attr.ib(default=0) - encoding: str = attr.ib(default="utf-8") + encoding: str | None = attr.ib(default="utf-8") class MQTT: @@ -566,7 +586,7 @@ class MQTT: self._mqttc: mqtt.Client = None self._paho_lock = asyncio.Lock() - self._pending_operations = {} + self._pending_operations: dict[str, asyncio.Event] = {} if self.hass.state == CoreState.running: self._ha_started.set() @@ -659,7 +679,7 @@ class MQTT: CONF_WILL_MESSAGE in self.conf and ATTR_TOPIC in self.conf[CONF_WILL_MESSAGE] ): - will_message = Message(**self.conf[CONF_WILL_MESSAGE]) + will_message = PublishMessage(**self.conf[CONF_WILL_MESSAGE]) else: will_message = None @@ -688,12 +708,12 @@ class MQTT: _raise_on_error(msg_info.rc) await self._wait_for_mid(msg_info.mid) - async def async_connect(self) -> str: + async def async_connect(self) -> None: """Connect to the host. Does not process messages yet.""" # pylint: disable=import-outside-toplevel import paho.mqtt.client as mqtt - result: int = None + result: int | None = None try: result = await self.hass.async_add_executor_job( self._mqttc.connect, @@ -770,7 +790,7 @@ class MQTT: This method is a coroutine. """ async with self._paho_lock: - result: int = None + result: int | None = None result, mid = await self.hass.async_add_executor_job( self._mqttc.unsubscribe, topic ) @@ -781,7 +801,7 @@ class MQTT: async def _async_perform_subscription(self, topic: str, qos: int) -> None: """Perform a paho-mqtt subscription.""" async with self._paho_lock: - result: int = None + result: int | None = None result, mid = await self.hass.async_add_executor_job( self._mqttc.subscribe, topic, qos ) @@ -836,7 +856,7 @@ class MQTT: retain=birth_message.retain, ) - birth_message = Message(**self.conf[CONF_BIRTH_MESSAGE]) + birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) asyncio.run_coroutine_threadsafe( publish_birth_message(birth_message), self.hass.loop ) @@ -883,7 +903,7 @@ class MQTT: self.hass.async_run_hass_job( subscription.job, - Message( + ReceiveMessage( msg.topic, payload, msg.qos, @@ -952,12 +972,12 @@ class MQTT: ) -def _raise_on_error(result_code: int) -> None: +def _raise_on_error(result_code: int | None) -> None: """Raise error if error result.""" # pylint: disable=import-outside-toplevel import paho.mqtt.client as mqtt - if result_code != 0: + if result_code is not None and result_code != 0: raise HomeAssistantError( f"Error talking to MQTT: {mqtt.error_string(result_code)}" ) @@ -1014,19 +1034,19 @@ async def websocket_remove_device(hass, connection, msg): ) -@websocket_api.async_response @websocket_api.websocket_command( { vol.Required("type"): "mqtt/subscribe", vol.Required("topic"): valid_subscribe_topic, } ) +@websocket_api.async_response async def websocket_subscribe(hass, connection, msg): """Subscribe to a MQTT topic.""" if not connection.user.is_admin: raise Unauthorized - async def forward_messages(mqttmsg: Message): + async def forward_messages(mqttmsg: ReceiveMessage): """Forward events to websocket.""" connection.send_message( websocket_api.event_message( @@ -1047,8 +1067,13 @@ async def websocket_subscribe(hass, connection, msg): connection.send_message(websocket_api.result_message(msg["id"])) +ConnectionStatusCallback = Callable[[bool], None] + + @callback -def async_subscribe_connection_status(hass, connection_status_callback): +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) @@ -1075,6 +1100,6 @@ def async_subscribe_connection_status(hass, connection_status_callback): return unsubscribe -def is_connected(hass): +def is_connected(hass: HomeAssistant) -> bool: """Return if MQTT client is connected.""" return hass.data[DATA_MQTT].connected diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index a2bd7fc6b36..6bb7a92e8af 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -74,6 +74,10 @@ ABBREVIATIONS = { "hs_val_tpl": "hs_value_template", "ic": "icon", "init": "initial", + "hum_cmd_t": "target_humidity_command_topic", + "hum_cmd_tpl": "target_humidity_command_template", + "hum_stat_t": "target_humidity_state_topic", + "hum_state_tpl": "target_humidity_state_template", "json_attr": "json_attributes", "json_attr_t": "json_attributes_topic", "json_attr_tpl": "json_attributes_template", @@ -81,14 +85,17 @@ ABBREVIATIONS = { "lrst_val_tpl": "last_reset_value_template", "max": "max", "min": "min", + "max_hum": "max_humidity", + "min_hum": "min_humidity", "max_mirs": "max_mireds", "min_mirs": "min_mireds", "max_temp": "max_temp", "min_temp": "min_temp", "mode_cmd_tpl": "mode_command_template", "mode_cmd_t": "mode_command_topic", - "mode_stat_tpl": "mode_state_template", "mode_stat_t": "mode_state_topic", + "mode_stat_tpl": "mode_state_template", + "modes": "modes", "name": "name", "off_dly": "off_delay", "on_cmd_type": "on_command_type", @@ -126,6 +133,8 @@ ABBREVIATIONS = { "pl_osc_off": "payload_oscillation_off", "pl_osc_on": "payload_oscillation_on", "pl_paus": "payload_pause", + "pl_rst_hum": "payload_reset_humidity", + "pl_rst_mode": "payload_reset_mode", "pl_rst_pct": "payload_reset_percentage", "pl_rst_pr_mode": "payload_reset_preset_mode", "pl_stop": "payload_stop", diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index e24abc27028..66dea3e3aa0 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -226,6 +226,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity): def available(self) -> bool: """Return true if the device is available and value has not expired.""" expire_after = self._config.get(CONF_EXPIRE_AFTER) - return MqttAvailability.available.fget(self) and ( + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return MqttAvailability.available.fget(self) and ( # type: ignore[attr-defined] expire_after is None or not self._expired ) diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index d00d65c2451..57cb88e65e3 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -1,7 +1,7 @@ """Helper to handle a set of topics to subscribe to.""" from collections import deque from functools import wraps -from typing import Any +from typing import Any, Callable from homeassistant.core import HomeAssistant @@ -12,7 +12,9 @@ DATA_MQTT_DEBUG_INFO = "mqtt_debug_info" STORED_MESSAGES = 10 -def log_messages(hass: HomeAssistant, entity_id: str) -> MessageCallbackType: +def log_messages( + hass: HomeAssistant, entity_id: str +) -> Callable[[MessageCallbackType], MessageCallbackType]: """Wrap an MQTT message callback to support message logging.""" def _log_message(msg): @@ -24,7 +26,7 @@ def log_messages(hass: HomeAssistant, entity_id: str) -> MessageCallbackType: if msg not in messages: messages.append(msg) - def _decorator(msg_callback: MessageCallbackType): + def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: @wraps(msg_callback) def wrapper(msg: Any) -> None: """Log message.""" diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index d9413b80c06..89246406de3 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -119,15 +119,15 @@ class Trigger: """Device trigger settings.""" device_id: str = attr.ib() - discovery_data: dict = attr.ib() + discovery_data: dict | None = attr.ib() hass: HomeAssistant = attr.ib() - payload: str = attr.ib() - qos: int = attr.ib() - remove_signal: Callable[[], None] = attr.ib() + payload: str | None = attr.ib() + qos: int | None = attr.ib() + remove_signal: Callable[[], None] | None = attr.ib() subtype: str = attr.ib() - topic: str = attr.ib() + topic: str | None = attr.ib() type: str = attr.ib() - value_template: str = attr.ib() + value_template: str | None = attr.ib() trigger_instances: list[TriggerInstance] = attr.ib(factory=list) async def add_trigger(self, action, automation_info): @@ -289,7 +289,7 @@ async def async_device_removed(hass: HomeAssistant, device_id: str): async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: """List device triggers for MQTT devices.""" - triggers = [] + triggers: list[dict] = [] if DEVICE_TRIGGERS not in hass.data: return triggers diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d35065e30a8..0659baa9144 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -41,6 +41,7 @@ SUPPORTED_COMPONENTS = [ "device_automation", "device_tracker", "fan", + "humidifier", "light", "lock", "number", @@ -83,7 +84,7 @@ class MQTTConfig(dict): async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic, config_entry=None -) -> bool: +) -> None: """Start MQTT Discovery.""" mqtt_integrations = {} @@ -275,8 +276,16 @@ async def async_start( # noqa: C901 if key not in hass.data[INTEGRATION_UNSUBSCRIBE]: return + data = { + "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=msg + integration, context={"source": DOMAIN}, data=data ) if ( result @@ -298,10 +307,8 @@ async def async_start( # noqa: C901 0, ) - return True - -async def async_stop(hass: HomeAssistant) -> bool: +async def async_stop(hass: HomeAssistant) -> None: """Stop MQTT Discovery.""" if DISCOVERY_UNSUBSCRIBE in hass.data: for unsub in hass.data[DISCOVERY_UNSUBSCRIBE]: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index ef996a3c4ba..552ee8da6d6 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -360,7 +360,7 @@ class MqttFan(MqttEntity, FanEntity): if self._feature_preset_mode: self._supported_features |= SUPPORT_PRESET_MODE - for tpl_dict in [self._command_templates, self._value_templates]: + for tpl_dict in (self._command_templates, self._value_templates): for key, tpl in tpl_dict.items(): if tpl is None: tpl_dict[key] = lambda value: value diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py new file mode 100644 index 00000000000..2f76b416184 --- /dev/null +++ b/homeassistant/components/mqtt/humidifier.py @@ -0,0 +1,456 @@ +"""Support for MQTT humidifiers.""" +import functools +import logging + +import voluptuous as vol + +from homeassistant.components import humidifier +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_DEHUMIDIFIER, + DEVICE_CLASS_HUMIDIFIER, + SUPPORT_MODES, + HumidifierEntity, +) +from homeassistant.const import ( + CONF_NAME, + CONF_OPTIMISTIC, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_STATE, +) +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + +from . import ( + CONF_COMMAND_TOPIC, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_TOPIC, + DOMAIN, + PLATFORMS, + subscription, +) +from .. import mqtt +from .debug_info import log_messages +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper + +CONF_AVAILABLE_MODES_LIST = "modes" +CONF_COMMAND_TEMPLATE = "command_template" +CONF_DEVICE_CLASS = "device_class" +CONF_MODE_COMMAND_TEMPLATE = "mode_command_template" +CONF_MODE_COMMAND_TOPIC = "mode_command_topic" +CONF_MODE_STATE_TOPIC = "mode_state_topic" +CONF_MODE_STATE_TEMPLATE = "mode_state_template" +CONF_PAYLOAD_RESET_MODE = "payload_reset_mode" +CONF_PAYLOAD_RESET_HUMIDITY = "payload_reset_humidity" +CONF_STATE_VALUE_TEMPLATE = "state_value_template" +CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE = "target_humidity_command_template" +CONF_TARGET_HUMIDITY_COMMAND_TOPIC = "target_humidity_command_topic" +CONF_TARGET_HUMIDITY_MIN = "min_humidity" +CONF_TARGET_HUMIDITY_MAX = "max_humidity" +CONF_TARGET_HUMIDITY_STATE_TEMPLATE = "target_humidity_state_template" +CONF_TARGET_HUMIDITY_STATE_TOPIC = "target_humidity_state_topic" + +DEFAULT_NAME = "MQTT Humidifier" +DEFAULT_OPTIMISTIC = False +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +DEFAULT_PAYLOAD_RESET = "None" + +MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED = frozenset( + { + humidifier.ATTR_HUMIDITY, + humidifier.ATTR_MAX_HUMIDITY, + humidifier.ATTR_MIN_HUMIDITY, + humidifier.ATTR_MODE, + humidifier.ATTR_AVAILABLE_MODES, + } +) + +_LOGGER = logging.getLogger(__name__) + + +def valid_mode_configuration(config): + """Validate that the mode reset payload is not one of the available modes.""" + if config.get(CONF_PAYLOAD_RESET_MODE) in config.get(CONF_AVAILABLE_MODES_LIST): + raise ValueError("modes must not contain payload_reset_mode") + return config + + +def valid_humidity_range_configuration(config): + """Validate that the target_humidity range configuration is valid, throws if it isn't.""" + if config.get(CONF_TARGET_HUMIDITY_MIN) >= config.get(CONF_TARGET_HUMIDITY_MAX): + raise ValueError("target_humidity_max must be > target_humidity_min") + if config.get(CONF_TARGET_HUMIDITY_MAX) > 100: + raise ValueError("max_humidity must be <= 100") + + return config + + +PLATFORM_SCHEMA = vol.All( + mqtt.MQTT_RW_PLATFORM_SCHEMA.extend( + { + # CONF_AVAIALABLE_MODES_LIST and CONF_MODE_COMMAND_TOPIC must be used together + vol.Inclusive( + CONF_AVAILABLE_MODES_LIST, "available_modes", default=[] + ): cv.ensure_list, + vol.Inclusive( + CONF_MODE_COMMAND_TOPIC, "available_modes" + ): mqtt.valid_publish_topic, + vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS, default=DEVICE_CLASS_HUMIDIFIER): vol.In( + [DEVICE_CLASS_HUMIDIFIER, DEVICE_CLASS_DEHUMIDIFIER] + ), + vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, + vol.Required(CONF_TARGET_HUMIDITY_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE): cv.template, + vol.Optional( + CONF_TARGET_HUMIDITY_MAX, default=DEFAULT_MAX_HUMIDITY + ): cv.positive_int, + vol.Optional( + CONF_TARGET_HUMIDITY_MIN, default=DEFAULT_MIN_HUMIDITY + ): cv.positive_int, + vol.Optional(CONF_TARGET_HUMIDITY_STATE_TEMPLATE): cv.template, + vol.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional( + CONF_PAYLOAD_RESET_HUMIDITY, default=DEFAULT_PAYLOAD_RESET + ): cv.string, + vol.Optional( + CONF_PAYLOAD_RESET_MODE, default=DEFAULT_PAYLOAD_RESET + ): cv.string, + } + ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema), + valid_humidity_range_configuration, + valid_mode_configuration, +) + + +async def async_setup_platform( + hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +): + """Set up MQTT humidifier through configuration.yaml.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await _async_setup_entity(hass, async_add_entities, config) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT humidifier dynamically through MQTT discovery.""" + setup = functools.partial( + _async_setup_entity, hass, async_add_entities, config_entry=config_entry + ) + await async_setup_entry_helper(hass, humidifier.DOMAIN, setup, PLATFORM_SCHEMA) + + +async def _async_setup_entity( + hass, async_add_entities, config, config_entry=None, discovery_data=None +): + """Set up the MQTT humidifier.""" + async_add_entities([MqttHumidifier(hass, config, config_entry, discovery_data)]) + + +class MqttHumidifier(MqttEntity, HumidifierEntity): + """A MQTT humidifier component.""" + + _attributes_extra_blocked = MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED + + def __init__(self, hass, config, config_entry, discovery_data): + """Initialize the MQTT humidifier.""" + self._state = False + self._target_humidity = None + self._mode = None + self._supported_features = 0 + + self._topic = None + self._payload = None + self._value_templates = None + self._command_templates = None + self._optimistic = None + self._optimistic_target_humidity = None + self._optimistic_mode = None + + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + @staticmethod + def config_schema(): + """Return the config schema.""" + return PLATFORM_SCHEMA + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_min_humidity = config.get(CONF_TARGET_HUMIDITY_MIN) + self._attr_max_humidity = config.get(CONF_TARGET_HUMIDITY_MAX) + + self._topic = { + key: config.get(key) + for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_TARGET_HUMIDITY_STATE_TOPIC, + CONF_TARGET_HUMIDITY_COMMAND_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_MODE_COMMAND_TOPIC, + ) + } + self._value_templates = { + CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), + ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_STATE_TEMPLATE), + ATTR_MODE: config.get(CONF_MODE_STATE_TEMPLATE), + } + self._command_templates = { + CONF_STATE: config.get(CONF_COMMAND_TEMPLATE), + ATTR_HUMIDITY: config.get(CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE), + ATTR_MODE: config.get(CONF_MODE_COMMAND_TEMPLATE), + } + self._payload = { + "STATE_ON": config[CONF_PAYLOAD_ON], + "STATE_OFF": config[CONF_PAYLOAD_OFF], + "HUMIDITY_RESET": config[CONF_PAYLOAD_RESET_HUMIDITY], + "MODE_RESET": config[CONF_PAYLOAD_RESET_MODE], + } + if CONF_MODE_COMMAND_TOPIC in config and CONF_AVAILABLE_MODES_LIST in config: + self._available_modes = config[CONF_AVAILABLE_MODES_LIST] + else: + self._available_modes = [] + if self._available_modes: + self._attr_supported_features = SUPPORT_MODES + else: + self._attr_supported_features = 0 + + optimistic = config[CONF_OPTIMISTIC] + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._optimistic_target_humidity = ( + optimistic or self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is None + ) + self._optimistic_mode = optimistic or self._topic[CONF_MODE_STATE_TOPIC] is None + + for tpl_dict in (self._command_templates, self._value_templates): + for key, tpl in tpl_dict.items(): + if tpl is None: + tpl_dict[key] = lambda value: value + else: + tpl.hass = self.hass + tpl_dict[key] = tpl.async_render_with_possible_json_value + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} + + @callback + @log_messages(self.hass, self.entity_id) + def state_received(msg): + """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._state = True + elif payload == self._payload["STATE_OFF"]: + self._state = False + self.async_write_ha_state() + + 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], + } + + @callback + @log_messages(self.hass, self.entity_id) + def target_humidity_received(msg): + """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._target_humidity = None + self.async_write_ha_state() + 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._target_humidity = target_humidity + self.async_write_ha_state() + + if self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is not None: + topics[CONF_TARGET_HUMIDITY_STATE_TOPIC] = { + "topic": self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC], + "msg_callback": target_humidity_received, + "qos": self._config[CONF_QOS], + } + self._target_humidity = None + + @callback + @log_messages(self.hass, self.entity_id) + def mode_received(msg): + """Handle new received MQTT message for mode.""" + mode = self._value_templates[ATTR_MODE](msg.payload) + if mode == self._payload["MODE_RESET"]: + self._mode = None + self.async_write_ha_state() + return + if not mode: + _LOGGER.debug("Ignoring empty mode from '%s'", msg.topic) + return + if 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._mode = mode + self.async_write_ha_state() + + if self._topic[CONF_MODE_STATE_TOPIC] is not None: + topics[CONF_MODE_STATE_TOPIC] = { + "topic": self._topic[CONF_MODE_STATE_TOPIC], + "msg_callback": mode_received, + "qos": self._config[CONF_QOS], + } + self._mode = None + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, topics + ) + + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + + @property + def available_modes(self) -> list: + """Get the list of available modes.""" + return self._available_modes + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def target_humidity(self): + """Return the current target humidity.""" + return self._target_humidity + + @property + def mode(self): + """Return the current mode.""" + return self._mode + + async def async_turn_on( + self, + **kwargs, + ) -> None: + """Turn on the entity. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) + mqtt.async_publish( + self.hass, + self._topic[CONF_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + if self._optimistic: + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off the entity. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) + mqtt.async_publish( + self.hass, + self._topic[CONF_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + if self._optimistic: + self._state = False + self.async_write_ha_state() + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier. + + This method is a coroutine. + """ + mqtt_payload = self._command_templates[ATTR_HUMIDITY](humidity) + mqtt.async_publish( + self.hass, + self._topic[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + + if self._optimistic_target_humidity: + self._target_humidity = humidity + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the fan. + + This method is a coroutine. + """ + if mode not in self.available_modes: + _LOGGER.warning("'%s'is not a valid mode", mode) + return + + mqtt_payload = self._command_templates[ATTR_MODE](mode) + + mqtt.async_publish( + self.hass, + self._topic[CONF_MODE_COMMAND_TOPIC], + mqtt_payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + ) + + if self._optimistic_mode: + self._mode = mode + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b0c8b573b37..a40f06a3bb6 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod import json import logging +from typing import Callable import voluptuous as vol @@ -37,7 +38,7 @@ from .discovery import ( clear_discovery_hash, set_discovery_hash, ) -from .models import Message +from .models import ReceiveMessage from .subscription import async_subscribe_topics, async_unsubscribe_topics from .util import valid_subscribe_topic @@ -194,11 +195,11 @@ async def async_setup_entry_helper(hass, domain, async_setup, schema): class MqttAttributes(Entity): """Mixin used for platforms that support JSON attributes.""" - _attributes_extra_blocked = frozenset() + _attributes_extra_blocked: frozenset[str] = frozenset() def __init__(self, config: dict) -> None: """Initialize the JSON attributes mixin.""" - self._attributes = None + self._attributes: dict | None = None self._attributes_sub_state = None self._attributes_config = config @@ -220,12 +221,12 @@ class MqttAttributes(Entity): @callback @log_messages(self.hass, self.entity_id) - def attributes_message_received(msg: Message) -> None: + def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = msg.payload if attr_tpl is not None: payload = attr_tpl.async_render_with_possible_json_value(payload) - json_dict = json.loads(payload) + json_dict = json.loads(payload) if isinstance(payload, str) else None if isinstance(json_dict, dict): filtered_dict = { k: v @@ -272,7 +273,7 @@ class MqttAvailability(Entity): def __init__(self, config: dict) -> None: """Initialize the availability mixin.""" self._availability_sub_state = None - self._available = {} + self._available: dict = {} self._available_latest = False self._availability_setup_from_config(config) @@ -317,7 +318,7 @@ class MqttAvailability(Entity): @callback @log_messages(self.hass, self.entity_id) - def availability_message_received(msg: Message) -> None: + def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic if msg.payload == self._avail_topics[topic][CONF_PAYLOAD_AVAILABLE]: @@ -397,7 +398,7 @@ class MqttDiscoveryUpdate(Entity): """Initialize the discovery update mixin.""" self._discovery_data = discovery_data self._discovery_update = discovery_update - self._remove_signal = None + self._remove_signal: Callable | None = None self._removed_from_hass = False async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 7cdafeef98d..5c320ac0827 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -2,23 +2,35 @@ from __future__ import annotations import datetime as dt -from typing import Callable, Union +from typing import Awaitable, Callable, Union import attr PublishPayloadType = Union[str, bytes, int, float, None] +ReceivePayloadType = Union[str, bytes] @attr.s(slots=True, frozen=True) -class Message: +class PublishMessage: """MQTT Message.""" topic: str = attr.ib() payload: PublishPayloadType = attr.ib() qos: int = attr.ib() retain: bool = attr.ib() - subscribed_topic: str | None = attr.ib(default=None) - timestamp: dt.datetime | None = attr.ib(default=None) -MessageCallbackType = Callable[[Message], None] +@attr.s(slots=True, frozen=True) +class ReceiveMessage: + """MQTT Message.""" + + topic: str = attr.ib() + payload: ReceivePayloadType = attr.ib() + qos: int = attr.ib() + retain: bool = attr.ib() + subscribed_topic: str = attr.ib(default=None) + timestamp: dt.datetime = attr.ib(default=None) + + +AsyncMessageCallbackType = Callable[[ReceiveMessage], Awaitable[None]] +MessageCallbackType = Callable[[ReceiveMessage], None] diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 98643917788..4f4d3fbb663 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -126,7 +126,10 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): if value_template is not None: payload = value_template.async_render_with_possible_json_value(payload) - if payload not in self.options: + if payload.lower() == "none": + payload = None + + if payload is not None and payload not in self.options: _LOGGER.error( "Invalid option for %s: '%s' (valid options: %s)", self.entity_id, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 777a15b639a..239af7b450a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -196,7 +196,7 @@ class MqttSensor(MqttEntity, SensorEntity): self.async_write_ha_state() if CONF_LAST_RESET_TOPIC in self._config: - topics["state_topic"] = { + topics["last_reset_topic"] = { "topic": self._config[CONF_LAST_RESET_TOPIC], "msg_callback": last_reset_message_received, "qos": self._config[CONF_QOS], @@ -242,6 +242,7 @@ class MqttSensor(MqttEntity, SensorEntity): def available(self) -> bool: """Return true if the device is available and value has not expired.""" expire_after = self._config.get(CONF_EXPIRE_AFTER) - return MqttAvailability.available.fget(self) and ( + # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 + return MqttAvailability.available.fget(self) and ( # type: ignore[attr-defined] expire_after is None or not self._expired ) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 6c711600b2c..03259a37380 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -66,7 +66,7 @@ async def async_subscribe_topics( hass: HomeAssistant, new_state: dict[str, EntitySubscription] | None, topics: dict[str, Any], -): +) -> dict[str, EntitySubscription]: """(Re)Subscribe to a set of MQTT topics. State is kept in sub_state and a dictionary mapping from the subscription @@ -106,6 +106,8 @@ async def async_subscribe_topics( @bind_hass -async def async_unsubscribe_topics(hass: HomeAssistant, sub_state: dict): +async 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 await async_subscribe_topics(hass, sub_state, {}) diff --git a/homeassistant/components/mqtt/translations/ar.json b/homeassistant/components/mqtt/translations/ar.json new file mode 100644 index 00000000000..ffdddc147df --- /dev/null +++ b/homeassistant/components/mqtt/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "broker": { + "description": "\u0627\u0644\u0631\u062c\u0627\u0621 \u0625\u062f\u062e\u0627\u0644 \u0645\u0639\u0644\u0648\u0645\u0627\u062a \u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0644\u0648\u0633\u064a\u0637 MQTT \u0627\u0644\u062e\u0627\u0635 \u0628\u0643." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/translations/de.json b/homeassistant/components/mqtt/translations/de.json index 132b4c42e18..2961a69ed1b 100644 --- a/homeassistant/components/mqtt/translations/de.json +++ b/homeassistant/components/mqtt/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich." + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" diff --git a/homeassistant/components/mqtt/translations/he.json b/homeassistant/components/mqtt/translations/he.json index ef628fb799b..f0c156b5fde 100644 --- a/homeassistant/components/mqtt/translations/he.json +++ b/homeassistant/components/mqtt/translations/he.json @@ -16,6 +16,10 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc \u05d4\u05d1\u05e8\u05d5\u05e7\u05e8 MQTT \u05e9\u05dc\u05da." + }, + "hassio_confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4-Home Assistant \u05db\u05da \u05e9\u05ea\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05ea\u05d5\u05d5\u05da MQTT \u05d4\u05de\u05e1\u05d5\u05e4\u05e7 \u05e2\u05dc \u05d9\u05d3\u05d9 \u05d4\u05d4\u05e8\u05d7\u05d1\u05d4 {addon}?", + "title": "MQTT \u05d1\u05e8\u05d5\u05e7\u05e8 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d4\u05e8\u05d7\u05d1\u05ea Home Assistant" } } }, @@ -30,9 +34,11 @@ "port": "\u05e4\u05ea\u05d7\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, + "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc \u05d4\u05d1\u05e8\u05d5\u05e7\u05e8 MQTT \u05e9\u05dc\u05da.", "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05de\u05ea\u05d5\u05d5\u05da" }, "options": { + "description": "\u05d2\u05d9\u05dc\u05d5\u05d9 - \u05d0\u05dd \u05d2\u05d9\u05dc\u05d5\u05d9 \u05de\u05d5\u05e4\u05e2\u05dc (\u05de\u05d5\u05de\u05dc\u05e5), Home Assistant \u05d9\u05d2\u05dc\u05d4 \u05d1\u05d0\u05d5\u05e4\u05df \u05d0\u05d5\u05d8\u05d5\u05de\u05d8\u05d9 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d5\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05d4\u05de\u05e4\u05e8\u05e1\u05de\u05d9\u05dd \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea\u05dd \u05d1\u05de\u05ea\u05d5\u05d5\u05da MQTT. \u05d0\u05dd \u05d4\u05d2\u05d9\u05dc\u05d5\u05d9 \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05e4\u05e2\u05dc, \u05db\u05dc \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05d7\u05d9\u05d9\u05d1\u05ea \u05dc\u05d4\u05d9\u05e2\u05e9\u05d5\u05ea \u05d1\u05d0\u05d5\u05e4\u05df \u05d9\u05d3\u05e0\u05d9.\n\u05d4\u05d5\u05d3\u05e2\u05ea \u05dc\u05d9\u05d3\u05d4 - \u05d4\u05d5\u05d3\u05e2\u05ea \u05d4\u05dc\u05d9\u05d3\u05d4 \u05ea\u05d9\u05e9\u05dc\u05d7 \u05d1\u05db\u05dc \u05e4\u05e2\u05dd \u05e9-Home Assistant \u05de\u05ea\u05d7\u05d1\u05e8 (\u05de\u05d7\u05d3\u05e9) \u05dc\u05de\u05ea\u05d5\u05d5\u05da MQTT.\n\u05d4\u05d5\u05d3\u05e2\u05ea \u05e8\u05e6\u05d5\u05df - \u05d4\u05d5\u05d3\u05e2\u05ea \u05d4\u05e8\u05e6\u05d5\u05df \u05ea\u05d9\u05e9\u05dc\u05d7 \u05d1\u05db\u05dc \u05e4\u05e2\u05dd \u05e9-Home Assistant \u05d9\u05d0\u05d1\u05d3 \u05d0\u05ea \u05d4\u05e7\u05e9\u05e8 \u05e9\u05dc\u05d5 \u05dc\u05de\u05ea\u05d5\u05d5\u05da, \u05d2\u05dd \u05d1\u05de\u05e7\u05e8\u05d4 \u05e9\u05dc \u05e0\u05d9\u05ea\u05d5\u05e7 \u05e0\u05e7\u05d9 (\u05dc\u05de\u05e9\u05dc \u05db\u05d9\u05d1\u05d5\u05d9 \u05e9\u05dc Home Assistant) \u05d5\u05d2\u05dd \u05d1\u05de\u05e7\u05e8\u05d4 \u05e9\u05dc \u05e0\u05d9\u05ea\u05d5\u05e7 \u05dc\u05d0 \u05e0\u05e7\u05d9 (\u05dc\u05de\u05e9\u05dc Home Assistant \u05de\u05ea\u05e8\u05e1\u05e7 \u05d0\u05d5 \u05de\u05d0\u05d1\u05d3 \u05d0\u05ea \u05d7\u05d9\u05d1\u05d5\u05e8 \u05d4\u05e8\u05e9\u05ea \u05e9\u05dc\u05d5).", "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea MQTT" } } diff --git a/homeassistant/components/mqtt/translations/hu.json b/homeassistant/components/mqtt/translations/hu.json index f265789d777..84c4a40f082 100644 --- a/homeassistant/components/mqtt/translations/hu.json +++ b/homeassistant/components/mqtt/translations/hu.json @@ -60,6 +60,9 @@ "port": "Port", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } + }, + "options": { + "title": "MQTT opci\u00f3k" } } } diff --git a/homeassistant/components/mullvad/translations/hu.json b/homeassistant/components/mullvad/translations/hu.json index e92d5c4bdea..aedebce2afc 100644 --- a/homeassistant/components/mullvad/translations/hu.json +++ b/homeassistant/components/mullvad/translations/hu.json @@ -6,6 +6,11 @@ "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "description": "Be\u00e1ll\u00edtja a Mullvad VPN integr\u00e1ci\u00f3t?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/mutesync/const.py b/homeassistant/components/mutesync/const.py index 5e288b405af..027a48f46ca 100644 --- a/homeassistant/components/mutesync/const.py +++ b/homeassistant/components/mutesync/const.py @@ -5,4 +5,4 @@ from typing import Final DOMAIN: Final = "mutesync" UPDATE_INTERVAL_NOT_IN_MEETING: Final = timedelta(seconds=10) -UPDATE_INTERVAL_IN_MEETING: Final = timedelta(seconds=5) +UPDATE_INTERVAL_IN_MEETING: Final = timedelta(seconds=10) diff --git a/homeassistant/components/mutesync/translations/hu.json b/homeassistant/components/mutesync/translations/hu.json new file mode 100644 index 00000000000..68cb5c18d27 --- /dev/null +++ b/homeassistant/components/mutesync/translations/hu.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "Enged\u00e9lyezze a hiteles\u00edt\u00e9st a m\u00fctesync be\u00e1ll\u00edt\u00e1sai > Hiteles\u00edt\u00e9s men\u00fcpontban", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "host": "Gazdag\u00e9p" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 16b061a3346..953fe4c69a8 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -202,7 +202,7 @@ class MVGLiveData: # now select the relevant data _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} - for k in ["destination", "linename", "time", "direction", "product"]: + for k in ("destination", "linename", "time", "direction", "product"): _nextdep[k] = _departure.get(k, "") _nextdep["time"] = int(_nextdep["time"]) self.departures.append(_nextdep) diff --git a/homeassistant/components/myq/translations/de.json b/homeassistant/components/myq/translations/de.json index 5d8b5acdc79..d4bc7f35928 100644 --- a/homeassistant/components/myq/translations/de.json +++ b/homeassistant/components/myq/translations/de.json @@ -15,14 +15,14 @@ "password": "Passwort" }, "description": "Das Passwort f\u00fcr {username} ist nicht mehr g\u00fcltig.", - "title": "Authentifizieren Sie Ihr MyQ-Konto erneut" + "title": "Authentifiziere dein MyQ-Konto erneut" }, "user": { "data": { "password": "Passwort", "username": "Benutzername" }, - "title": "Stellen Sie eine Verbindung zum MyQ Gateway her" + "title": "Stelle eine Verbindung zum MyQ Gateway her" } } } diff --git a/homeassistant/components/myq/translations/fr.json b/homeassistant/components/myq/translations/fr.json index e9a6bc60b82..c07e3710645 100644 --- a/homeassistant/components/myq/translations/fr.json +++ b/homeassistant/components/myq/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "MyQ est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "Impossible de se connecter, veuillez r\u00e9essayer", diff --git a/homeassistant/components/myq/translations/hu.json b/homeassistant/components/myq/translations/hu.json index 9c5b90e7447..59338cf43ae 100644 --- a/homeassistant/components/myq/translations/hu.json +++ b/homeassistant/components/myq/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,6 +10,13 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "A(z) {username} jelszava m\u00e1r nem \u00e9rv\u00e9nyes.", + "title": "Hiteles\u00edtse \u00fajra MyQ-fi\u00f3kj\u00e1t" + }, "user": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 2a958cee060..3d0f219c2a8 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -231,10 +231,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def finish() -> None: await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS_WITH_ENTRY_SUPPORT - ] + ) ) await finish_setup(hass, entry, gateway) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index f1e2cd0a4e1..f9410f66e8f 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN from homeassistant.components.mqtt.models import ( - Message as MQTTMessage, - PublishPayloadType, + ReceiveMessage as MQTTReceiveMessage, + ReceivePayloadType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -188,12 +188,12 @@ async def _get_gateway( mqtt.async_publish(topic, payload, qos, retain) def sub_callback( - topic: str, sub_cb: Callable[[str, PublishPayloadType, int], None], qos: int + topic: str, sub_cb: Callable[[str, ReceivePayloadType, int], None], qos: int ) -> None: """Call MQTT subscribe function.""" @callback - def internal_callback(msg: MQTTMessage) -> None: + def internal_callback(msg: MQTTReceiveMessage) -> None: """Call callback.""" sub_cb(msg.topic, msg.payload, msg.qos) diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index b08d94cebb0..e1f4dd3d1e0 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -163,6 +163,8 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if self.assumed_state: # optimistically assume that light has changed state + # pylint: disable=no-value-for-parameter + # https://github.com/PyCQA/pylint/issues/4546 self._hs = color_util.color_RGB_to_hs(*rgb) # type: ignore[assignment] self._white = white self._values[self.value_type] = hex_color diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 2ede5e38c6a..c7755b13512 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,67 +1,108 @@ """Support for MySensors sensors.""" from __future__ import annotations +from datetime import datetime + from awesomeversion import AwesomeVersion from homeassistant.components import mysensors -from homeassistant.components.sensor import DOMAIN, SensorEntity +from homeassistant.components.sensor import ( + DOMAIN, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONDUCTIVITY, DEGREE, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_MILLIVOLT, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_METERS, LIGHT_LUX, MASS_KILOGRAMS, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, + SOUND_PRESSURE_DB, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.dt import utc_from_timestamp from .const import MYSENSORS_DISCOVERY, DiscoveryInfo from .helpers import on_unload SENSORS: dict[str, list[str | None] | dict[str, list[str | None]]] = { - "V_TEMP": [None, "mdi:thermometer"], - "V_HUM": [PERCENTAGE, "mdi:water-percent"], - "V_DIMMER": [PERCENTAGE, "mdi:percent"], - "V_PERCENTAGE": [PERCENTAGE, "mdi:percent"], - "V_PRESSURE": [None, "mdi:gauge"], - "V_FORECAST": [None, "mdi:weather-partly-cloudy"], - "V_RAIN": [None, "mdi:weather-rainy"], - "V_RAINRATE": [None, "mdi:weather-rainy"], - "V_WIND": [None, "mdi:weather-windy"], - "V_GUST": [None, "mdi:weather-windy"], - "V_DIRECTION": [DEGREE, "mdi:compass"], - "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram"], - "V_DISTANCE": [LENGTH_METERS, "mdi:ruler"], - "V_IMPEDANCE": ["ohm", None], - "V_WATT": [POWER_WATT, None], - "V_KWH": [ENERGY_KILO_WATT_HOUR, None], - "V_LIGHT_LEVEL": [PERCENTAGE, "mdi:white-balance-sunny"], - "V_FLOW": [LENGTH_METERS, "mdi:gauge"], - "V_VOLUME": [f"{VOLUME_CUBIC_METERS}", None], + "V_TEMP": [None, None, DEVICE_CLASS_TEMPERATURE, STATE_CLASS_MEASUREMENT], + "V_HUM": [ + PERCENTAGE, + "mdi:water-percent", + DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, + ], + "V_DIMMER": [PERCENTAGE, "mdi:percent", None, None], + "V_PERCENTAGE": [PERCENTAGE, "mdi:percent", None, None], + "V_PRESSURE": [None, "mdi:gauge", None, None], + "V_FORECAST": [None, "mdi:weather-partly-cloudy", None, None], + "V_RAIN": [None, "mdi:weather-rainy", None, None], + "V_RAINRATE": [None, "mdi:weather-rainy", None, None], + "V_WIND": [None, "mdi:weather-windy", None, None], + "V_GUST": [None, "mdi:weather-windy", None, None], + "V_DIRECTION": [DEGREE, "mdi:compass", None, None], + "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram", None, None], + "V_DISTANCE": [LENGTH_METERS, "mdi:ruler", None, None], + "V_IMPEDANCE": ["ohm", None, None, None], + "V_WATT": [POWER_WATT, None, DEVICE_CLASS_POWER, STATE_CLASS_MEASUREMENT], + "V_KWH": [ + ENERGY_KILO_WATT_HOUR, + None, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + ], + "V_LIGHT_LEVEL": [PERCENTAGE, "mdi:white-balance-sunny", None, None], + "V_FLOW": [LENGTH_METERS, "mdi:gauge", None, None], + "V_VOLUME": [VOLUME_CUBIC_METERS, None, None, None], "V_LEVEL": { - "S_SOUND": ["dB", "mdi:volume-high"], - "S_VIBRATION": [FREQUENCY_HERTZ, None], - "S_LIGHT_LEVEL": [LIGHT_LUX, "mdi:white-balance-sunny"], + "S_SOUND": [SOUND_PRESSURE_DB, "mdi:volume-high", None, None], + "S_VIBRATION": [FREQUENCY_HERTZ, None, None, None], + "S_LIGHT_LEVEL": [ + LIGHT_LUX, + "mdi:white-balance-sunny", + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + ], }, - "V_VOLTAGE": [VOLT, "mdi:flash"], - "V_CURRENT": [ELECTRICAL_CURRENT_AMPERE, "mdi:flash-auto"], - "V_PH": ["pH", None], - "V_ORP": ["mV", None], - "V_EC": [CONDUCTIVITY, None], - "V_VAR": ["var", None], - "V_VA": [ELECTRICAL_VOLT_AMPERE, None], + "V_VOLTAGE": [ + ELECTRIC_POTENTIAL_VOLT, + "mdi:flash", + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + ], + "V_CURRENT": [ + ELECTRIC_CURRENT_AMPERE, + "mdi:flash-auto", + DEVICE_CLASS_CURRENT, + STATE_CLASS_MEASUREMENT, + ], + "V_PH": ["pH", None, None, None], + "V_ORP": [ELECTRIC_POTENTIAL_MILLIVOLT, None, None, None], + "V_EC": [CONDUCTIVITY, None, None, None], + "V_VAR": ["var", None, None, None], + "V_VA": [POWER_VOLT_AMPERE, None, None, None], } @@ -107,14 +148,32 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): @property def state(self) -> str | None: - """Return the state of the device.""" + """Return the state of this entity.""" return self._values.get(self.value_type) + @property + def device_class(self) -> str | None: + """Return the device class of this entity.""" + return self._get_sensor_type()[2] + @property def icon(self) -> str | None: """Return the icon to use in the frontend, if any.""" - icon = self._get_sensor_type()[1] - return icon + return self._get_sensor_type()[1] + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + set_req = self.gateway.const.SetReq + + if set_req(self.value_type).name == "V_KWH": + return utc_from_timestamp(0) + return None + + @property + def state_class(self) -> str | None: + """Return the state class of this entity.""" + return self._get_sensor_type()[3] @property def unit_of_measurement(self) -> str | None: @@ -140,9 +199,13 @@ class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): pres = self.gateway.const.Presentation set_req = self.gateway.const.SetReq - _sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None]) + _sensor_type = SENSORS.get( + set_req(self.value_type).name, [None, None, None, None] + ) if isinstance(_sensor_type, dict): - sensor_type = _sensor_type.get(pres(self.child_type).name, [None, None]) + sensor_type = _sensor_type.get( + pres(self.child_type).name, [None, None, None, None] + ) else: sensor_type = _sensor_type return sensor_type diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json index 54821877b4f..d7722e565cb 100644 --- a/homeassistant/components/mysensors/strings.json +++ b/homeassistant/components/mysensors/strings.json @@ -1,5 +1,4 @@ { - "title": "MySensors", "config": { "step": { "user": { diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index cdb4979d16b..8f8c759c364 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -1,7 +1,6 @@ """Support for MySensors switches.""" from __future__ import annotations -from contextlib import suppress from typing import Any import voluptuous as vol @@ -109,18 +108,6 @@ async def async_setup_entry( class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" - @property - def current_power_w(self) -> float | None: - """Return the current power usage in W.""" - set_req = self.gateway.const.SetReq - value = self._values.get(set_req.V_WATT) - float_value: float | None = None - if value is not None: - with suppress(ValueError): - float_value = float(value) - - return float_value - @property def is_on(self) -> bool: """Return True if switch is on.""" diff --git a/homeassistant/components/mysensors/translations/de.json b/homeassistant/components/mysensors/translations/de.json index bb6a1ed7bfe..cfef6f7d363 100644 --- a/homeassistant/components/mysensors/translations/de.json +++ b/homeassistant/components/mysensors/translations/de.json @@ -76,5 +76,5 @@ } } }, - "title": "" + "title": "MySensors" } \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/he.json b/homeassistant/components/mysensors/translations/he.json index 7ded555edb8..587c3ae9132 100644 --- a/homeassistant/components/mysensors/translations/he.json +++ b/homeassistant/components/mysensors/translations/he.json @@ -10,7 +10,13 @@ "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "mqtt_required": "\u05e9\u05d9\u05dc\u05d5\u05d1 MQTT \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05d2\u05d3\u05e8", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "gw_mqtt": { + "description": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05e9\u05e2\u05e8 MQTT" + } } } } \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/hu.json b/homeassistant/components/mysensors/translations/hu.json index fefe3fd4b6c..e9d9caaeb4f 100644 --- a/homeassistant/components/mysensors/translations/hu.json +++ b/homeassistant/components/mysensors/translations/hu.json @@ -3,36 +3,76 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "duplicate_persistence_file": "A perzisztencia f\u00e1jl m\u00e1r haszn\u00e1latban van", + "duplicate_topic": "A t\u00e9ma m\u00e1r haszn\u00e1latban van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "invalid_device": "\u00c9rv\u00e9nytelen eszk\u00f6z", + "invalid_ip": "\u00c9rv\u00e9nytelen IP-c\u00edm", + "invalid_persistence_file": "\u00c9rv\u00e9nytelen perzisztencia f\u00e1jl", + "invalid_port": "\u00c9rv\u00e9nytelen portsz\u00e1m", + "invalid_publish_topic": "\u00c9rv\u00e9nytelen k\u00f6zz\u00e9t\u00e9teli t\u00e9ma", + "invalid_serial": "\u00c9rv\u00e9nytelen soros port", + "invalid_subscribe_topic": "\u00c9rv\u00e9nytelen feliratkoz\u00e1si t\u00e9ma", + "invalid_version": "\u00c9rv\u00e9nytelen MySensors verzi\u00f3", "not_a_number": "Adj meg egy sz\u00e1mot.", + "port_out_of_range": "A portsz\u00e1mnak legal\u00e1bb 1-nek \u00e9s legfeljebb 65535-nek kell lennie", + "same_topic": "A feliratkoz\u00e1s \u00e9s a k\u00f6zz\u00e9t\u00e9tel t\u00e9m\u00e1i ugyanazok", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "duplicate_persistence_file": "A perzisztencia f\u00e1jl m\u00e1r haszn\u00e1latban van", + "duplicate_topic": "A t\u00e9ma m\u00e1r haszn\u00e1latban van", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "invalid_device": "\u00c9rv\u00e9nytelen eszk\u00f6z", + "invalid_ip": "\u00c9rv\u00e9nytelen IP-c\u00edm", + "invalid_persistence_file": "\u00c9rv\u00e9nytelen perzisztencia f\u00e1jl", + "invalid_port": "\u00c9rv\u00e9nytelen portsz\u00e1m", + "invalid_publish_topic": "\u00c9rv\u00e9nytelen k\u00f6zz\u00e9t\u00e9teli t\u00e9ma", "invalid_serial": "\u00c9rv\u00e9nytelen soros port", + "invalid_subscribe_topic": "\u00c9rv\u00e9nytelen feliratkoz\u00e1si t\u00e9ma", "invalid_version": "\u00c9rv\u00e9nytelen MySensors verzi\u00f3", + "mqtt_required": "Az MQTT integr\u00e1ci\u00f3 nincs be\u00e1ll\u00edtva", + "not_a_number": "K\u00e9rj\u00fck, adja meg a sz\u00e1mot", "port_out_of_range": "A portsz\u00e1mnak legal\u00e1bb 1-nek \u00e9s legfeljebb 65535-nek kell lennie", + "same_topic": "A feliratkoz\u00e1s \u00e9s a k\u00f6zz\u00e9t\u00e9tel t\u00e9m\u00e1i ugyanazok", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "gw_mqtt": { "data": { + "persistence_file": "perzisztencia f\u00e1jl (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", + "retain": "mqtt megtart\u00e1sa", + "topic_in_prefix": "el\u0151tag a beviteli t\u00e9m\u00e1khoz (topic_in_prefix)", + "topic_out_prefix": "el\u0151tag a kimeneti t\u00e9m\u00e1khoz (topic_out_prefix)", "version": "MySensors verzi\u00f3" - } + }, + "description": "MQTT \u00e1tj\u00e1r\u00f3 be\u00e1ll\u00edt\u00e1sa" }, "gw_serial": { "data": { + "baud_rate": "\u00e1tviteli sebess\u00e9g", + "device": "Soros port", + "persistence_file": "perzisztencia f\u00e1jl (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", "version": "MySensors verzi\u00f3" - } + }, + "description": "Soros \u00e1tj\u00e1r\u00f3 be\u00e1ll\u00edt\u00e1sa" }, "gw_tcp": { "data": { + "device": "Az \u00e1tj\u00e1r\u00f3 IP-c\u00edme", + "persistence_file": "perzisztencia f\u00e1jl (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)", "tcp_port": "port", "version": "MySensors verzi\u00f3" - } + }, + "description": "Ethernet \u00e1tj\u00e1r\u00f3 be\u00e1ll\u00edt\u00e1sa" + }, + "user": { + "data": { + "gateway_type": "\u00c1tj\u00e1r\u00f3 t\u00edpusa" + }, + "description": "V\u00e1lassza ki az \u00e1tj\u00e1r\u00f3hoz val\u00f3 csatlakoz\u00e1si m\u00f3dot" } } }, diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 97d54fb0669..52e506fc4dd 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -54,7 +54,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Remove air_quality entities from registry if they exist ent_reg = entity_registry.async_get(hass) - for sensor_type in ["sds", ATTR_SDS011, ATTR_SPS30]: + for sensor_type in ("sds", ATTR_SDS011, ATTR_SPS30): unique_id = f"{coordinator.unique_id}-{sensor_type}" if entity_id := ent_reg.async_get_entity_id( AIR_QUALITY_PLATFORM, DOMAIN, unique_id diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py index f60d03eea78..85472deba06 100644 --- a/homeassistant/components/nam/const.py +++ b/homeassistant/components/nam/const.py @@ -4,10 +4,11 @@ from __future__ import annotations from datetime import timedelta from typing import Final -from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntityDescription, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ICON, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_CO2, @@ -22,8 +23,6 @@ from homeassistant.const import ( TEMP_CELSIUS, ) -from .model import SensorDescription - SUFFIX_P0: Final = "_p0" SUFFIX_P1: Final = "_p1" SUFFIX_P2: Final = "_p2" @@ -52,10 +51,6 @@ ATTR_SPS30_P2: Final = f"{ATTR_SPS30}{SUFFIX_P2}" ATTR_SPS30_P4: Final = f"{ATTR_SPS30}{SUFFIX_P4}" ATTR_UPTIME: Final = "uptime" -ATTR_ENABLED: Final = "enabled" -ATTR_LABEL: Final = "label" -ATTR_UNIT: Final = "unit" - DEFAULT_NAME: Final = "Nettigo Air Monitor" DEFAULT_UPDATE_INTERVAL: Final = timedelta(minutes=6) DOMAIN: Final = "nam" @@ -66,165 +61,145 @@ MIGRATION_SENSORS: Final = [ ("humidity", ATTR_DHT22_HUMIDITY), ] -SENSORS: Final[dict[str, SensorDescription]] = { - ATTR_BME280_HUMIDITY: { - ATTR_LABEL: f"{DEFAULT_NAME} BME280 Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BME280_PRESSURE: { - ATTR_LABEL: f"{DEFAULT_NAME} BME280 Pressure", - ATTR_UNIT: PRESSURE_HPA, - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BME280_TEMPERATURE: { - ATTR_LABEL: f"{DEFAULT_NAME} BME280 Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BMP280_PRESSURE: { - ATTR_LABEL: f"{DEFAULT_NAME} BMP280 Pressure", - ATTR_UNIT: PRESSURE_HPA, - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_BMP280_TEMPERATURE: { - ATTR_LABEL: f"{DEFAULT_NAME} BMP280 Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_HECA_HUMIDITY: { - ATTR_LABEL: f"{DEFAULT_NAME} HECA Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_HECA_TEMPERATURE: { - ATTR_LABEL: f"{DEFAULT_NAME} HECA Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_MHZ14A_CARBON_DIOXIDE: { - ATTR_LABEL: f"{DEFAULT_NAME} MH-Z14A Carbon Dioxide", - ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, - ATTR_DEVICE_CLASS: DEVICE_CLASS_CO2, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SDS011_P1: { - ATTR_LABEL: f"{DEFAULT_NAME} SDS011 Particulate Matter 10", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SDS011_P2: { - ATTR_LABEL: f"{DEFAULT_NAME} SDS011 Particulate Matter 2.5", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SHT3X_HUMIDITY: { - ATTR_LABEL: f"{DEFAULT_NAME} SHT3X Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SHT3X_TEMPERATURE: { - ATTR_LABEL: f"{DEFAULT_NAME} SHT3X Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SPS30_P0: { - ATTR_LABEL: f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SPS30_P1: { - ATTR_LABEL: f"{DEFAULT_NAME} SPS30 Particulate Matter 10", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SPS30_P2: { - ATTR_LABEL: f"{DEFAULT_NAME} SPS30 Particulate Matter 2.5", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SPS30_P4: { - ATTR_LABEL: f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", - ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:blur", - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DHT22_HUMIDITY: { - ATTR_LABEL: f"{DEFAULT_NAME} DHT22 Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_DHT22_TEMPERATURE: { - ATTR_LABEL: f"{DEFAULT_NAME} DHT22 Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_ENABLED: True, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_SIGNAL_STRENGTH: { - ATTR_LABEL: f"{DEFAULT_NAME} Signal Strength", - ATTR_UNIT: SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - ATTR_DEVICE_CLASS: DEVICE_CLASS_SIGNAL_STRENGTH, - ATTR_ICON: None, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, - }, - ATTR_UPTIME: { - ATTR_LABEL: f"{DEFAULT_NAME} Uptime", - ATTR_UNIT: None, - ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, - ATTR_ICON: None, - ATTR_ENABLED: False, - ATTR_STATE_CLASS: None, - }, -} +SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + key=ATTR_BME280_HUMIDITY, + name=f"{DEFAULT_NAME} BME280 Humidity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BME280_PRESSURE, + name=f"{DEFAULT_NAME} BME280 Pressure", + unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BME280_TEMPERATURE, + name=f"{DEFAULT_NAME} BME280 Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BMP280_PRESSURE, + name=f"{DEFAULT_NAME} BMP280 Pressure", + unit_of_measurement=PRESSURE_HPA, + device_class=DEVICE_CLASS_PRESSURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_BMP280_TEMPERATURE, + name=f"{DEFAULT_NAME} BMP280 Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_HECA_HUMIDITY, + name=f"{DEFAULT_NAME} HECA Humidity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_HECA_TEMPERATURE, + name=f"{DEFAULT_NAME} HECA Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_MHZ14A_CARBON_DIOXIDE, + name=f"{DEFAULT_NAME} MH-Z14A Carbon Dioxide", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=DEVICE_CLASS_CO2, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SDS011_P1, + name=f"{DEFAULT_NAME} SDS011 Particulate Matter 10", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SDS011_P2, + name=f"{DEFAULT_NAME} SDS011 Particulate Matter 2.5", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SHT3X_HUMIDITY, + name=f"{DEFAULT_NAME} SHT3X Humidity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SHT3X_TEMPERATURE, + name=f"{DEFAULT_NAME} SHT3X Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SPS30_P0, + name=f"{DEFAULT_NAME} SPS30 Particulate Matter 1.0", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SPS30_P1, + name=f"{DEFAULT_NAME} SPS30 Particulate Matter 10", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SPS30_P2, + name=f"{DEFAULT_NAME} SPS30 Particulate Matter 2.5", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SPS30_P4, + name=f"{DEFAULT_NAME} SPS30 Particulate Matter 4.0", + unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + icon="mdi:blur", + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DHT22_HUMIDITY, + name=f"{DEFAULT_NAME} DHT22 Humidity", + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_DHT22_TEMPERATURE, + name=f"{DEFAULT_NAME} DHT22 Temperature", + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_SIGNAL_STRENGTH, + name=f"{DEFAULT_NAME} Signal Strength", + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + entity_registry_enabled_default=False, + state_class=STATE_CLASS_MEASUREMENT, + ), + SensorEntityDescription( + key=ATTR_UPTIME, + name=f"{DEFAULT_NAME} Uptime", + device_class=DEVICE_CLASS_TIMESTAMP, + entity_registry_enabled_default=False, + ), +) diff --git a/homeassistant/components/nam/model.py b/homeassistant/components/nam/model.py deleted file mode 100644 index 0cadaad647e..00000000000 --- a/homeassistant/components/nam/model.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Type definitions for Nettig Air Monitor integration.""" -from __future__ import annotations - -from typing import TypedDict - - -class SensorDescription(TypedDict): - """Sensor description class.""" - - label: str - unit: str | None - device_class: str | None - icon: str | None - enabled: bool - state_class: str | None diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py index ae3d1e639d5..298f88d5c29 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -6,12 +6,11 @@ import logging from typing import cast from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, DOMAIN as PLATFORM, SensorEntity, + SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -20,15 +19,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utcnow from . import NAMDataUpdateCoordinator -from .const import ( - ATTR_ENABLED, - ATTR_LABEL, - ATTR_UNIT, - ATTR_UPTIME, - DOMAIN, - MIGRATION_SENSORS, - SENSORS, -) +from .const import ATTR_UPTIME, DOMAIN, MIGRATION_SENSORS, SENSORS PARALLEL_UPDATES = 1 @@ -57,12 +48,12 @@ async def async_setup_entry( ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) sensors: list[NAMSensor | NAMSensorUptime] = [] - for sensor in SENSORS: - if getattr(coordinator.data, sensor) is not None: - if sensor == ATTR_UPTIME: - sensors.append(NAMSensorUptime(coordinator, sensor)) + for description in SENSORS: + if getattr(coordinator.data, description.key) is not None: + if description.key == ATTR_UPTIME: + sensors.append(NAMSensorUptime(coordinator, description)) else: - sensors.append(NAMSensor(coordinator, sensor)) + sensors.append(NAMSensor(coordinator, description)) async_add_entities(sensors, False) @@ -72,24 +63,23 @@ class NAMSensor(CoordinatorEntity, SensorEntity): coordinator: NAMDataUpdateCoordinator - def __init__(self, coordinator: NAMDataUpdateCoordinator, sensor_type: str) -> None: + def __init__( + self, + coordinator: NAMDataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: """Initialize.""" super().__init__(coordinator) - description = SENSORS[sensor_type] - self._attr_device_class = description[ATTR_DEVICE_CLASS] self._attr_device_info = coordinator.device_info - self._attr_entity_registry_enabled_default = description[ATTR_ENABLED] - self._attr_icon = description[ATTR_ICON] - self._attr_name = description[ATTR_LABEL] - self._attr_state_class = description[ATTR_STATE_CLASS] - self._attr_unique_id = f"{coordinator.unique_id}-{sensor_type}" - self._attr_unit_of_measurement = description[ATTR_UNIT] - self.sensor_type = sensor_type + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self.entity_description = description @property def state(self) -> StateType: """Return the state.""" - return cast(StateType, getattr(self.coordinator.data, self.sensor_type)) + return cast( + StateType, getattr(self.coordinator.data, self.entity_description.key) + ) @property def available(self) -> bool: @@ -100,7 +90,8 @@ class NAMSensor(CoordinatorEntity, SensorEntity): # sensors. For this reason, we mark entities for which data is missing as # unavailable. return ( - available and getattr(self.coordinator.data, self.sensor_type) is not None + available + and getattr(self.coordinator.data, self.entity_description.key) is not None ) @@ -110,7 +101,7 @@ class NAMSensorUptime(NAMSensor): @property def state(self) -> str: """Return the state.""" - uptime_sec = getattr(self.coordinator.data, self.sensor_type) + uptime_sec = getattr(self.coordinator.data, self.entity_description.key) return ( (utcnow() - timedelta(seconds=uptime_sec)) .replace(microsecond=0) diff --git a/homeassistant/components/nam/translations/fr.json b/homeassistant/components/nam/translations/fr.json index 0c58af2a800..1800e6da508 100644 --- a/homeassistant/components/nam/translations/fr.json +++ b/homeassistant/components/nam/translations/fr.json @@ -4,6 +4,10 @@ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", "device_unsupported": "L'appareil n'est pas pris en charge." }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, "flow_title": "{nom}", "step": { "confirm_discovery": { diff --git a/homeassistant/components/nam/translations/hu.json b/homeassistant/components/nam/translations/hu.json new file mode 100644 index 00000000000..8776ae92e20 --- /dev/null +++ b/homeassistant/components/nam/translations/hu.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "device_unsupported": "Az eszk\u00f6z nem t\u00e1mogatott." + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "unknown": "V\u00e1ratlan hiba" + }, + "flow_title": "{name}", + "step": { + "confirm_discovery": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a Nettigo Air Monitor-ot a {host} c\u00edmen?" + }, + "user": { + "data": { + "host": "Gazdag\u00e9p" + }, + "description": "\u00c1ll\u00edtsa be a Nettigo Air Monitor integr\u00e1ci\u00f3j\u00e1t." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/id.json b/homeassistant/components/nam/translations/id.json new file mode 100644 index 00000000000..e289d14dd37 --- /dev/null +++ b/homeassistant/components/nam/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "device_unsupported": "Perangkat tidak didukung." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name}", + "step": { + "user": { + "data": { + "host": "Host" + }, + "description": "Siapkan integrasi Nettigo Air Monitor." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 21af0f91d17..20848ccff08 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -18,6 +18,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "title": "Neato Botvac" -} \ No newline at end of file + } +} diff --git a/homeassistant/components/neato/translations/de.json b/homeassistant/components/neato/translations/de.json index dac965a13bf..c7fd239c585 100644 --- a/homeassistant/components/neato/translations/de.json +++ b/homeassistant/components/neato/translations/de.json @@ -4,7 +4,7 @@ "already_configured": "Ger\u00e4t ist bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", - "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler sind [im Hilfebereich]({docs_url}) zu finden", + "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "create_entry": { @@ -15,7 +15,7 @@ "title": "W\u00e4hle die Authentifizierungsmethode" }, "reauth_confirm": { - "title": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "title": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json index 92de680c17a..a94bf08f7c3 100644 --- a/homeassistant/components/nederlandse_spoorwegen/manifest.json +++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json @@ -2,7 +2,7 @@ "domain": "nederlandse_spoorwegen", "name": "Nederlandse Spoorwegen (NS)", "documentation": "https://www.home-assistant.io/integrations/nederlandse_spoorwegen", - "requirements": ["nsapi==3.0.4"], + "requirements": ["nsapi==3.0.5"], "codeowners": ["@YarmoM"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index fb488763750..b999b2e94e0 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -33,7 +33,6 @@ from .const import DATA_SDM, DATA_SUBSCRIBER, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_T from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) CONF_PROJECT_ID = "project_id" @@ -70,7 +69,7 @@ CONFIG_SCHEMA = vol.Schema( PLATFORMS = ["sensor", "camera", "climate"] -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} @@ -110,7 +109,7 @@ class SignalUpdateCallback: """Initialize EventCallback.""" self._hass = hass - async def async_handle_event(self, event_message: EventMessage): + async def async_handle_event(self, event_message: EventMessage) -> None: """Process an incoming EventMessage.""" if not event_message.resource_update_name: return @@ -195,7 +194,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: # Legacy API diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 29f39f5aec3..426a651461a 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -1,6 +1,7 @@ """API for Google Nest Device Access bound to Home Assistant OAuth.""" import datetime +from typing import cast from aiohttp import ClientSession from google.oauth2.credentials import Credentials @@ -29,13 +30,13 @@ class AsyncConfigEntryAuth(AbstractAuth): self._client_id = client_id self._client_secret = client_secret - async def async_get_access_token(self): + async def async_get_access_token(self) -> str: """Return a valid access token for SDM API.""" if not self._oauth_session.valid_token: await self._oauth_session.async_ensure_token_valid() - return self._oauth_session.token["access_token"] + return cast(str, self._oauth_session.token["access_token"]) - async def async_get_creds(self): + async def async_get_creds(self) -> Credentials: """Return an OAuth credential for Pub/Sub Subscriber.""" # We don't have a way for Home Assistant to refresh creds on behalf # of the google pub/sub subscriber. Instead, build a full diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py index 0bf65f2163c..6d9331744ef 100644 --- a/homeassistant/components/nest/binary_sensor.py +++ b/homeassistant/components/nest/binary_sensor.py @@ -2,13 +2,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_SDM from .legacy.binary_sensor import async_setup_legacy_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the binary sensors.""" assert DATA_SDM not in entry.data diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index ca117f0cbf1..7ae3e0db943 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -2,6 +2,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .camera_sdm import async_setup_sdm_entry from .const import DATA_SDM @@ -9,7 +10,7 @@ from .legacy.camera import async_setup_legacy_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the cameras.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index f8f2db506e2..5f5fdbc8d93 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -3,13 +3,17 @@ from __future__ import annotations import datetime import logging +from typing import Any, Callable from google_nest_sdm.camera_traits import ( CameraEventImageTrait, CameraImageTrait, CameraLiveStreamTrait, + EventImageGenerator, + RtspStream, ) from google_nest_sdm.device import Device +from google_nest_sdm.event import ImageEventBase from google_nest_sdm.exceptions import GoogleNestException from haffmpeg.tools import IMAGE_JPEG @@ -18,11 +22,13 @@ from homeassistant.components.ffmpeg import async_get_image from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from .const import DATA_SUBSCRIBER, DOMAIN -from .device_info import DeviceInfo +from .device_info import NestDeviceInfo _LOGGER = logging.getLogger(__name__) @@ -31,7 +37,7 @@ STREAM_EXPIRATION_BUFFER = datetime.timedelta(seconds=30) async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the cameras.""" @@ -60,13 +66,13 @@ class NestCamera(Camera): """Initialize the camera.""" super().__init__() self._device = device - self._device_info = DeviceInfo(device) - self._stream = None - self._stream_refresh_unsub = None + self._device_info = NestDeviceInfo(device) + self._stream: RtspStream | None = None + self._stream_refresh_unsub: Callable[[], None] | None = None # Cache of most recent event image - self._event_id = None - self._event_image_bytes = None - self._event_image_cleanup_unsub = None + self._event_id: str | None = None + self._event_image_bytes: bytes | None = None + self._event_image_cleanup_unsub: Callable[[], None] | None = None @property def should_poll(self) -> bool: @@ -74,40 +80,40 @@ class NestCamera(Camera): return False @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return a unique ID.""" # The API "name" field is a unique device identifier. return f"{self._device.name}-camera" @property - def name(self): + def name(self) -> str | None: """Return the name of the camera.""" return self._device_info.device_name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return self._device_info.device_info @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return self._device_info.device_brand @property - def model(self): + def model(self) -> str | None: """Return the camera model.""" return self._device_info.device_model @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if CameraLiveStreamTrait.NAME in self._device.traits: supported_features |= SUPPORT_STREAM return supported_features - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" if CameraLiveStreamTrait.NAME not in self._device.traits: return None @@ -116,12 +122,14 @@ class NestCamera(Camera): _LOGGER.debug("Fetching stream url") self._stream = await trait.generate_rtsp_stream() self._schedule_stream_refresh() + assert self._stream if self._stream.expires_at < utcnow(): _LOGGER.warning("Stream already expired") return self._stream.rtsp_stream_url - def _schedule_stream_refresh(self): + def _schedule_stream_refresh(self) -> None: """Schedules an alarm to refresh the stream url before expiration.""" + assert self._stream _LOGGER.debug("New stream url expires at %s", self._stream.expires_at) refresh_time = self._stream.expires_at - STREAM_EXPIRATION_BUFFER # Schedule an alarm to extend the stream @@ -134,7 +142,7 @@ class NestCamera(Camera): refresh_time, ) - async def _handle_stream_refresh(self, now): + async def _handle_stream_refresh(self, now: datetime.datetime) -> None: """Alarm that fires to check if the stream should be refreshed.""" if not self._stream: return @@ -154,7 +162,7 @@ class NestCamera(Camera): self.stream.update_source(self._stream.rtsp_stream_url) self._schedule_stream_refresh() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Invalidates the RTSP token when unloaded.""" if self._stream: _LOGGER.debug("Invalidating stream") @@ -166,13 +174,13 @@ class NestCamera(Camera): if self._event_image_cleanup_unsub is not None: self._event_image_cleanup_unsub() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) - async def async_camera_image(self): + async def async_camera_image(self) -> bytes | None: """Return bytes of camera image.""" # Returns the snapshot of the last event for ~30 seconds after the event active_event_image = await self._async_active_event_image() @@ -184,7 +192,7 @@ class NestCamera(Camera): return None return await async_get_image(self.hass, stream_url, output_format=IMAGE_JPEG) - async def _async_active_event_image(self): + async def _async_active_event_image(self) -> bytes | None: """Return image from any active events happening.""" if CameraEventImageTrait.NAME not in self._device.traits: return None @@ -192,7 +200,11 @@ class NestCamera(Camera): if not trait: return None # Reuse image bytes if they have already been fetched - event = trait.last_event + if not isinstance(trait, EventImageGenerator): + return None + event: ImageEventBase | None = trait.last_event + if not event: + return None if self._event_id is not None and self._event_id == event.event_id: return self._event_image_bytes _LOGGER.debug("Generating event image URL for event_id %s", event.event_id) @@ -204,7 +216,9 @@ class NestCamera(Camera): self._schedule_event_image_cleanup(event.expires_at) return image_bytes - async def _async_fetch_active_event_image(self, trait): + async def _async_fetch_active_event_image( + self, trait: EventImageGenerator + ) -> bytes | None: """Return image bytes for an active event.""" try: event_image = await trait.generate_active_event_image() @@ -219,7 +233,7 @@ class NestCamera(Camera): _LOGGER.debug("Unable to fetch event image: %s", err) return None - def _schedule_event_image_cleanup(self, point_in_time): + def _schedule_event_image_cleanup(self, point_in_time: datetime.datetime) -> None: """Schedules an alarm to remove the image bytes from memory, honoring expiration.""" if self._event_image_cleanup_unsub is not None: self._event_image_cleanup_unsub() @@ -229,7 +243,7 @@ class NestCamera(Camera): point_in_time, ) - def _handle_event_image_cleanup(self, now): + def _handle_event_image_cleanup(self, now: Any) -> None: """Clear images cached from events and scheduled callback.""" self._event_id = None self._event_image_bytes = None diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 1644cc46004..372909d00c2 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -2,6 +2,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .climate_sdm import async_setup_sdm_entry from .const import DATA_SDM @@ -9,7 +10,7 @@ from .legacy.climate import async_setup_legacy_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the climate platform.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index ab987ff332f..04954cc7a07 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -1,11 +1,14 @@ """Support for Google Nest SDM climate devices.""" from __future__ import annotations +from typing import Any, cast + from google_nest_sdm.device import Device from google_nest_sdm.device_traits import FanTrait, TemperatureTrait from google_nest_sdm.exceptions import GoogleNestException from google_nest_sdm.thermostat_traits import ( ThermostatEcoTrait, + ThermostatHeatCoolTrait, ThermostatHvacTrait, ThermostatModeTrait, ThermostatTemperatureSetpointTrait, @@ -37,12 +40,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_SUBSCRIBER, DOMAIN -from .device_info import DeviceInfo +from .device_info import NestDeviceInfo # Mapping for sdm.devices.traits.ThermostatMode mode field -THERMOSTAT_MODE_MAP = { +THERMOSTAT_MODE_MAP: dict[str, str] = { "OFF": HVAC_MODE_OFF, "HEAT": HVAC_MODE_HEAT, "COOL": HVAC_MODE_COOL, @@ -78,7 +83,7 @@ MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the client entities.""" @@ -101,7 +106,7 @@ class ThermostatEntity(ClimateEntity): def __init__(self, device: Device) -> None: """Initialize ThermostatEntity.""" self._device = device - self._device_info = DeviceInfo(device) + self._device_info = NestDeviceInfo(device) self._supported_features = 0 @property @@ -116,16 +121,16 @@ class ThermostatEntity(ClimateEntity): return self._device.name @property - def name(self): + def name(self) -> str | None: """Return the name of the entity.""" return self._device_info.device_name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return self._device_info.device_info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self._supported_features = self._get_supported_features() self.async_on_remove( @@ -133,20 +138,20 @@ class ThermostatEntity(ClimateEntity): ) @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of temperature measurement for the system.""" return TEMP_CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" if TemperatureTrait.NAME not in self._device.traits: return None - trait = self._device.traits[TemperatureTrait.NAME] + trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] return trait.ambient_temperature_celsius @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature currently set to be reached.""" trait = self._target_temperature_trait if not trait: @@ -158,7 +163,7 @@ class ThermostatEntity(ClimateEntity): return None @property - def target_temperature_high(self): + def target_temperature_high(self) -> float | None: """Return the upper bound target temperature.""" if self.hvac_mode != HVAC_MODE_HEAT_COOL: return None @@ -168,7 +173,7 @@ class ThermostatEntity(ClimateEntity): return trait.cool_celsius @property - def target_temperature_low(self): + def target_temperature_low(self) -> float | None: """Return the lower bound target temperature.""" if self.hvac_mode != HVAC_MODE_HEAT_COOL: return None @@ -178,19 +183,26 @@ class ThermostatEntity(ClimateEntity): return trait.heat_celsius @property - def _target_temperature_trait(self): + def _target_temperature_trait( + self, + ) -> ThermostatHeatCoolTrait | None: """Return the correct trait with a target temp depending on mode.""" if ( self.preset_mode == PRESET_ECO and ThermostatEcoTrait.NAME in self._device.traits ): - return self._device.traits[ThermostatEcoTrait.NAME] + return cast( + ThermostatEcoTrait, self._device.traits[ThermostatEcoTrait.NAME] + ) if ThermostatTemperatureSetpointTrait.NAME in self._device.traits: - return self._device.traits[ThermostatTemperatureSetpointTrait.NAME] + return cast( + ThermostatTemperatureSetpointTrait, + self._device.traits[ThermostatTemperatureSetpointTrait.NAME], + ) return None @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return the current operation (e.g. heat, cool, idle).""" hvac_mode = HVAC_MODE_OFF if ThermostatModeTrait.NAME in self._device.traits: @@ -202,7 +214,7 @@ class ThermostatEntity(ClimateEntity): return hvac_mode @property - def hvac_modes(self): + def hvac_modes(self) -> list[str]: """List of available operation modes.""" supported_modes = [] for mode in self._get_device_hvac_modes: @@ -213,7 +225,7 @@ class ThermostatEntity(ClimateEntity): return supported_modes @property - def _get_device_hvac_modes(self): + def _get_device_hvac_modes(self) -> set[str]: """Return the set of SDM API hvac modes supported by the device.""" modes = [] if ThermostatModeTrait.NAME in self._device.traits: @@ -222,7 +234,7 @@ class ThermostatEntity(ClimateEntity): return set(modes) @property - def hvac_action(self): + def hvac_action(self) -> str | None: """Return the current HVAC action (heating, cooling).""" trait = self._device.traits[ThermostatHvacTrait.NAME] if trait.status in THERMOSTAT_HVAC_STATUS_MAP: @@ -230,7 +242,7 @@ class ThermostatEntity(ClimateEntity): return None @property - def preset_mode(self): + def preset_mode(self) -> str: """Return the current active preset.""" if ThermostatEcoTrait.NAME in self._device.traits: trait = self._device.traits[ThermostatEcoTrait.NAME] @@ -238,7 +250,7 @@ class ThermostatEntity(ClimateEntity): return PRESET_NONE @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Return the available presets.""" modes = [] if ThermostatEcoTrait.NAME in self._device.traits: @@ -249,7 +261,7 @@ class ThermostatEntity(ClimateEntity): return modes @property - def fan_mode(self): + def fan_mode(self) -> str: """Return the current fan mode.""" if FanTrait.NAME in self._device.traits: trait = self._device.traits[FanTrait.NAME] @@ -257,7 +269,7 @@ class ThermostatEntity(ClimateEntity): return FAN_OFF @property - def fan_modes(self): + def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" modes = [] if FanTrait.NAME in self._device.traits: @@ -265,11 +277,11 @@ class ThermostatEntity(ClimateEntity): return modes @property - def supported_features(self): + def supported_features(self) -> int: """Bitmap of supported features.""" return self._supported_features - def _get_supported_features(self): + def _get_supported_features(self) -> int: """Compute the bitmap of supported features from the current state.""" features = 0 if HVAC_MODE_HEAT_COOL in self.hvac_modes: @@ -285,7 +297,7 @@ class ThermostatEntity(ClimateEntity): features |= SUPPORT_FAN_MODE return features - async def async_set_hvac_mode(self, hvac_mode): + async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" if hvac_mode not in self.hvac_modes: raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") @@ -297,7 +309,7 @@ class ThermostatEntity(ClimateEntity): trait = self._device.traits[ThermostatModeTrait.NAME] await trait.set_mode(api_mode) - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) @@ -313,14 +325,14 @@ class ThermostatEntity(ClimateEntity): elif self.hvac_mode == HVAC_MODE_HEAT and temp: await trait.set_heat(temp) - async def async_set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" if preset_mode not in self.preset_modes: raise ValueError(f"Unsupported preset_mode '{preset_mode}'") trait = self._device.traits[ThermostatEcoTrait.NAME] await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) - async def async_set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index c6ebe543c99..1ec3e421a0d 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -17,11 +17,13 @@ import asyncio from collections import OrderedDict import logging import os +from typing import Any import async_timeout import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.json import load_json @@ -33,7 +35,13 @@ _LOGGER = logging.getLogger(__name__) @callback -def register_flow_implementation(hass, domain, name, gen_authorize_url, convert_code): +def register_flow_implementation( + hass: HomeAssistant, + domain: str, + name: str, + gen_authorize_url: str, + convert_code: str, +) -> None: """Register a flow implementation for legacy api. domain: Domain of the component responsible for the implementation. @@ -72,20 +80,20 @@ class NestFlowHandler( DOMAIN = DOMAIN VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() # When invoked for reauth, allows updating an existing config entry self._reauth = False @classmethod - def register_sdm_api(cls, hass): + def register_sdm_api(cls, hass: HomeAssistant) -> None: """Configure the flow handler to use the SDM API.""" if DOMAIN not in hass.data: hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_SDM] = {} - def is_sdm_api(self): + def is_sdm_api(self) -> bool: """Return true if this flow is setup to use SDM API.""" return DOMAIN in self.hass.data and DATA_SDM in self.hass.data[DOMAIN] @@ -104,7 +112,7 @@ class NestFlowHandler( "prompt": "consent", } - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the SDM flow.""" assert self.is_sdm_api(), "Step only supported for SDM API" data[DATA_SDM] = {} @@ -128,13 +136,17 @@ class NestFlowHandler( return await super().async_oauth_create_entry(data) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Perform reauth upon an API authentication error.""" assert self.is_sdm_api(), "Step only supported for SDM API" self._reauth = True # Forces update of existing config entry return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm reauth dialog.""" assert self.is_sdm_api(), "Step only supported for SDM API" if user_input is None: @@ -144,7 +156,9 @@ class NestFlowHandler( ) return await self.async_step_user() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if self.is_sdm_api(): # Reauth will update an existing entry @@ -153,7 +167,9 @@ class NestFlowHandler( return await super().async_step_user(user_input) return await self.async_step_init(user_input) - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" assert not self.is_sdm_api(), "Step only supported for legacy API" @@ -178,7 +194,9 @@ class NestFlowHandler( data_schema=vol.Schema({vol.Required("flow_impl"): vol.In(list(flows))}), ) - async def async_step_link(self, user_input=None): + async def async_step_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Attempt to link with the Nest account. Route the user to a website to authenticate with Nest. Depending on @@ -225,7 +243,7 @@ class NestFlowHandler( errors=errors, ) - async def async_step_import(self, info): + async def async_step_import(self, info: dict[str, Any]) -> FlowResult: """Import existing auth from Nest.""" assert not self.is_sdm_api(), "Step only supported for legacy API" @@ -235,7 +253,7 @@ class NestFlowHandler( config_path = info["nest_conf_path"] if not await self.hass.async_add_executor_job(os.path.isfile, config_path): - self.flow_impl = DOMAIN + self.flow_impl = DOMAIN # type: ignore return await self.async_step_link() flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] @@ -246,7 +264,9 @@ class NestFlowHandler( ) @callback - def _entry_from_tokens(self, title, flow, tokens): + def _entry_from_tokens( + self, title: str, flow: dict[str, Any], tokens: list[Any] | dict[Any, Any] + ) -> FlowResult: """Create an entry from tokens.""" return self.async_create_entry( title=title, data={"tokens": tokens, "impl_domain": flow["domain"]} diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 579733de8ad..6278547f216 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -1,11 +1,15 @@ """Library for extracting device specific information common to entities.""" +from __future__ import annotations + from google_nest_sdm.device import Device from google_nest_sdm.device_traits import InfoTrait +from homeassistant.helpers.entity import DeviceInfo + from .const import DOMAIN -DEVICE_TYPE_MAP = { +DEVICE_TYPE_MAP: dict[str, str] = { "sdm.devices.types.CAMERA": "Camera", "sdm.devices.types.DISPLAY": "Display", "sdm.devices.types.DOORBELL": "Doorbell", @@ -13,7 +17,7 @@ DEVICE_TYPE_MAP = { } -class DeviceInfo: +class NestDeviceInfo: """Provide device info from the SDM device, shared across platforms.""" device_brand = "Google Nest" @@ -23,21 +27,23 @@ class DeviceInfo: self._device = device @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" - return { - # The API "name" field is a unique device identifier. - "identifiers": {(DOMAIN, self._device.name)}, - "name": self.device_name, - "manufacturer": self.device_brand, - "model": self.device_model, - } + return DeviceInfo( + { + # The API "name" field is a unique device identifier. + "identifiers": {(DOMAIN, self._device.name)}, + "name": self.device_name, + "manufacturer": self.device_brand, + "model": self.device_model, + } + ) @property - def device_name(self): + def device_name(self) -> str: """Return the name of the physical device that includes the sensor.""" if InfoTrait.NAME in self._device.traits: - trait = self._device.traits[InfoTrait.NAME] + trait: InfoTrait = self._device.traits[InfoTrait.NAME] if trait.custom_name: return trait.custom_name # Build a name from the room/structure. Note: This room/structure name @@ -50,9 +56,11 @@ class DeviceInfo: return self.device_model @property - def device_model(self): + def device_model(self) -> str: """Return device model information.""" # The API intentionally returns minimal information about specific # devices, instead relying on traits, but we can infer a generic model # name based on the type - return DEVICE_TYPE_MAP.get(self._device.type) + if self._device.type in DEVICE_TYPE_MAP: + return DEVICE_TYPE_MAP[self._device.type] + return "Unknown" diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 4ed492a15fa..980d9726467 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -11,6 +11,7 @@ from homeassistant.components.device_automation.exceptions import ( from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.typing import ConfigType from .const import DATA_SUBSCRIBER, DOMAIN @@ -27,13 +28,16 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str: +async def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str | None: """Get the nest API device_id from the HomeAssistant device_id.""" - device_registry = await hass.helpers.device_registry.async_get_registry() + device_registry: DeviceRegistry = ( + await hass.helpers.device_registry.async_get_registry() + ) device = device_registry.async_get(device_id) - for (domain, unique_id) in device.identifiers: - if domain == DOMAIN: - return unique_id + if device: + for (domain, unique_id) in device.identifiers: + if domain == DOMAIN: + return unique_id return None diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index b0083dcf990..04f7b1ac663 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -96,7 +96,7 @@ def nest_update_event_broker(hass, nest): _LOGGER.debug("Stop listening for nest.update_event") -async def async_setup_legacy(hass, config): +async def async_setup_legacy(hass, config) -> bool: """Set up Nest components using the legacy nest API.""" if DOMAIN not in config: return True @@ -122,7 +122,7 @@ async def async_setup_legacy(hass, config): return True -async def async_setup_legacy_entry(hass, entry): +async def async_setup_legacy_entry(hass, entry) -> bool: """Set up Nest from legacy config entry.""" nest = Nest(access_token=entry.data["tokens"]["access_token"]) diff --git a/homeassistant/components/nest/legacy/binary_sensor.py b/homeassistant/components/nest/legacy/binary_sensor.py index 32c30f747d2..c257ddd9456 100644 --- a/homeassistant/components/nest/legacy/binary_sensor.py +++ b/homeassistant/components/nest/legacy/binary_sensor.py @@ -61,7 +61,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """ -async def async_setup_legacy_entry(hass, entry, async_add_entities): +async def async_setup_legacy_entry(hass, entry, async_add_entities) -> None: """Set up a Nest binary sensor based on a config entry.""" nest = hass.data[DATA_NEST] diff --git a/homeassistant/components/nest/legacy/camera.py b/homeassistant/components/nest/legacy/camera.py index cc9be9d7588..77629e4dcff 100644 --- a/homeassistant/components/nest/legacy/camera.py +++ b/homeassistant/components/nest/legacy/camera.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """ -async def async_setup_legacy_entry(hass, entry, async_add_entities): +async def async_setup_legacy_entry(hass, entry, async_add_entities) -> None: """Set up a Nest sensor based on a config entry.""" camera_devices = await hass.async_add_executor_job(hass.data[DATA_NEST].cameras) cameras = [NestCamera(structure, device) for structure, device in camera_devices] diff --git a/homeassistant/components/nest/legacy/climate.py b/homeassistant/components/nest/legacy/climate.py index cd0d66acba8..17448d9be8c 100644 --- a/homeassistant/components/nest/legacy/climate.py +++ b/homeassistant/components/nest/legacy/climate.py @@ -74,7 +74,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """ -async def async_setup_legacy_entry(hass, entry, async_add_entities): +async def async_setup_legacy_entry(hass, entry, async_add_entities) -> None: """Set up the Nest climate device based on a config entry.""" temp_unit = hass.config.units.temperature_unit diff --git a/homeassistant/components/nest/legacy/sensor.py b/homeassistant/components/nest/legacy/sensor.py index 53d9c824466..0939e925b43 100644 --- a/homeassistant/components/nest/legacy/sensor.py +++ b/homeassistant/components/nest/legacy/sensor.py @@ -77,7 +77,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """ -async def async_setup_legacy_entry(hass, entry, async_add_entities): +async def async_setup_legacy_entry(hass, entry, async_add_entities) -> None: """Set up a Nest sensor based on a config entry.""" nest = hass.data[DATA_NEST] @@ -93,9 +93,9 @@ async def async_setup_legacy_entry(hass, entry, async_add_entities): if variable in _SENSOR_TYPES_DEPRECATED: if variable in DEPRECATED_WEATHER_VARS: wstr = ( - "Nest no longer provides weather data like %s. See " + f"Nest no longer provides weather data like {variable}. See " "https://www.home-assistant.io/integrations/#weather " - "for a list of other weather integrations to use." % variable + "for a list of other weather integrations to use." ) else: wstr = ( diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 201ae40583e..6c9462e43db 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["ffmpeg", "http"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.2.12"], + "requirements": ["python-nest==4.1.0", "google-nest-sdm==0.3.5"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index c58ad26112d..a9073aec80d 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -2,6 +2,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_SDM from .legacy.sensor import async_setup_legacy_entry @@ -9,7 +10,7 @@ from .sensor_sdm import async_setup_sdm_entry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index 8182ef3ed95..42614af8c40 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -17,9 +17,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_SUBSCRIBER, DOMAIN -from .device_info import DeviceInfo +from .device_info import NestDeviceInfo _LOGGER = logging.getLogger(__name__) @@ -33,7 +35,7 @@ DEVICE_TYPE_MAP = { async def async_setup_sdm_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the sensors.""" @@ -44,7 +46,7 @@ async def async_setup_sdm_entry( _LOGGER.warning("Failed to get devices: %s", err) raise PlatformNotReady from err - entities = [] + entities: list[SensorEntity] = [] for device in device_manager.devices.values(): if TemperatureTrait.NAME in device.traits: entities.append(TemperatureSensor(device)) @@ -59,7 +61,7 @@ class SensorBase(SensorEntity): def __init__(self, device: Device) -> None: """Initialize the sensor.""" self._device = device - self._device_info = DeviceInfo(device) + self._device_info = NestDeviceInfo(device) @property def should_poll(self) -> bool: @@ -73,11 +75,11 @@ class SensorBase(SensorEntity): return f"{self._device.name}-{self.device_class}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device specific attributes.""" return self._device_info.device_info - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) @@ -88,23 +90,23 @@ class TemperatureSensor(SensorBase): """Representation of a Temperature Sensor.""" @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return f"{self._device_info.device_name} Temperature" @property - def state(self): + def state(self) -> float: """Return the state of the sensor.""" - trait = self._device.traits[TemperatureTrait.NAME] + trait: TemperatureTrait = self._device.traits[TemperatureTrait.NAME] return trait.ambient_temperature_celsius @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def device_class(self): + def device_class(self) -> str: """Return the class of this device.""" return DEVICE_CLASS_TEMPERATURE @@ -119,22 +121,22 @@ class HumiditySensor(SensorBase): return f"{self._device.name}-humidity" @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return f"{self._device_info.device_name} Humidity" @property - def state(self): + def state(self) -> float: """Return the state of the sensor.""" - trait = self._device.traits[HumidityTrait.NAME] + trait: HumidityTrait = self._device.traits[HumidityTrait.NAME] return trait.ambient_humidity_percent @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str: """Return the unit of measurement.""" return PERCENTAGE @property - def device_class(self): + def device_class(self) -> str: """Return the class of this device.""" return DEVICE_CLASS_HUMIDITY diff --git a/homeassistant/components/nest/translations/ar.json b/homeassistant/components/nest/translations/ar.json new file mode 100644 index 00000000000..31e904a6c54 --- /dev/null +++ b/homeassistant/components/nest/translations/ar.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "unknown": "\u062d\u062f\u062b \u062e\u0637\u0623 \u063a\u064a\u0631 \u0645\u062a\u0648\u0642\u0639" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u0645\u0632\u0648\u062f" + }, + "title": "\u0645\u0632\u0648\u062f \u0627\u0644\u0645\u0635\u0627\u062f\u0642\u0629" + }, + "link": { + "data": { + "code": "\u0631\u0645\u0632 PIN" + }, + "title": "\u0631\u0628\u0637 \u062d\u0633\u0627\u0628 Nest" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/he.json b/homeassistant/components/nest/translations/he.json index 91274d8b731..a3b5411b536 100644 --- a/homeassistant/components/nest/translations/he.json +++ b/homeassistant/components/nest/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "authorize_url_timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05e2\u05d1\u05d5\u05e8 \u05d9\u05e6\u05d9\u05e8\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea", + "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7", @@ -14,7 +14,7 @@ "internal_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05e4\u05e0\u05d9\u05de\u05d9\u05ea \u05d1\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3", "invalid_pin": "\u05e7\u05d5\u05d3 PIN \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", "timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "init": { diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index d92e50107c9..edb8837fd18 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,4 +1,6 @@ """The Netatmo integration.""" +from __future__ import annotations + import logging import secrets @@ -67,7 +69,7 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Netatmo component.""" hass.data[DOMAIN] = { DATA_PERSONS: {}, @@ -121,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - async def unregister_webhook(_): + async def unregister_webhook(_: None) -> None: if CONF_WEBHOOK_ID not in entry.data: return _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) @@ -138,7 +140,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID] ) - async def register_webhook(event): + async def register_webhook(_: None) -> None: if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} hass.config_entries.async_update_entry(entry, data=data) @@ -175,7 +177,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_handle_webhook, ) - async def handle_event(event): + async def handle_event(event: dict) -> None: """Handle webhook events.""" if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION: if activation_listener is not None: @@ -219,7 +221,7 @@ async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}") -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) @@ -236,7 +238,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Cleanup when entry is removed.""" if ( CONF_WEBHOOK_ID in entry.data diff --git a/homeassistant/components/netatmo/api.py b/homeassistant/components/netatmo/api.py index 19dfdac359b..e13032dc399 100644 --- a/homeassistant/components/netatmo/api.py +++ b/homeassistant/components/netatmo/api.py @@ -1,4 +1,6 @@ """API for Netatmo bound to HASS OAuth.""" +from typing import cast + from aiohttp import ClientSession import pyatmo @@ -17,8 +19,8 @@ class AsyncConfigEntryNetatmoAuth(pyatmo.auth.AbstractAsyncAuth): super().__init__(websession) self._oauth_session = oauth_session - async def async_get_access_token(self): + async def async_get_access_token(self) -> str: """Return a valid access token for Netatmo API.""" if not self._oauth_session.valid_token: await self._oauth_session.async_ensure_token_valid() - return self._oauth_session.token["access_token"] + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 7004ef0c472..346f9e93647 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,15 +1,20 @@ """Support for the Netatmo cameras.""" +from __future__ import annotations + import logging +from typing import Any, cast import aiohttp import pyatmo import voluptuous as vol from homeassistant.components.camera import SUPPORT_STREAM, Camera -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_CAMERA_LIGHT_MODE, @@ -35,7 +40,7 @@ from .const import ( WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import CAMERA_DATA_CLASS_NAME +from .data_handler import CAMERA_DATA_CLASS_NAME, NetatmoDataHandler from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -43,7 +48,9 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_QUALITY = "high" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Netatmo camera platform.""" if "access_camera" not in entry.data["token"]["scope"]: _LOGGER.info( @@ -108,12 +115,12 @@ class NetatmoCamera(NetatmoBase, Camera): def __init__( self, - data_handler, - camera_id, - camera_type, - home_id, - quality, - ): + data_handler: NetatmoDataHandler, + camera_id: str, + camera_type: str, + home_id: str, + quality: str, + ) -> None: """Set up for access to the Netatmo camera images.""" Camera.__init__(self) super().__init__(data_handler) @@ -124,17 +131,17 @@ class NetatmoCamera(NetatmoBase, Camera): self._id = camera_id self._home_id = home_id - self._device_name = self._data.get_camera(camera_id=camera_id).get("name") - self._name = f"{MANUFACTURER} {self._device_name}" + self._device_name = self._data.get_camera(camera_id=camera_id)["name"] + self._attr_name = f"{MANUFACTURER} {self._device_name}" self._model = camera_type - self._unique_id = f"{self._id}-{self._model}" + self._attr_unique_id = f"{self._id}-{self._model}" self._quality = quality - self._vpnurl = None - self._localurl = None - self._status = None - self._sd_status = None - self._alim_status = None - self._is_local = None + self._vpnurl: str | None = None + self._localurl: str | None = None + self._status: str | None = None + self._sd_status: str | None = None + self._alim_status: str | None = None + self._is_local: str | None = None self._light_state = None async def async_added_to_hass(self) -> None: @@ -153,7 +160,7 @@ class NetatmoCamera(NetatmoBase, Camera): self.hass.data[DOMAIN][DATA_CAMERAS][self._id] = self._device_name @callback - def handle_event(self, event): + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -172,11 +179,22 @@ class NetatmoCamera(NetatmoBase, Camera): self._status = "on" elif data[WEBHOOK_PUSH_TYPE] == WEBHOOK_LIGHT_MODE: self._light_state = data["sub_type"] + self._attr_extra_state_attributes.update( + {"light_state": self._light_state} + ) self.async_write_ha_state() return - async def async_camera_image(self): + @property + def _data(self) -> pyatmo.AsyncCameraData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncCameraData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + + async def async_camera_image(self) -> bytes | None: """Return a still image response from the camera.""" try: return await self._data.async_get_live_snapshot(camera_id=self._id) @@ -191,57 +209,43 @@ class NetatmoCamera(NetatmoBase, Camera): return None @property - def extra_state_attributes(self): - """Return the Netatmo-specific camera state attributes.""" - return { - "id": self._id, - "status": self._status, - "sd_status": self._sd_status, - "alim_status": self._alim_status, - "is_local": self._is_local, - "vpn_url": self._vpnurl, - "local_url": self._localurl, - "light_state": self._light_state, - } - - @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._alim_status == "on" or self._status == "disconnected") @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return SUPPORT_STREAM @property - def brand(self): + def brand(self) -> str: """Return the camera brand.""" return MANUFACTURER @property - def motion_detection_enabled(self): + def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" return bool(self._status == "on") @property - def is_on(self): + def is_on(self) -> bool: """Return true if on.""" return self.is_streaming - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn off camera.""" await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, monitoring="off" ) - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn on camera.""" await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, monitoring="on" ) - async def stream_source(self): + async def stream_source(self) -> str: """Return the stream source.""" url = "{0}/live/files/{1}/index.m3u8" if self._localurl: @@ -249,12 +253,12 @@ class NetatmoCamera(NetatmoBase, Camera): return url.format(self._vpnurl, self._quality) @property - def model(self): + def model(self) -> str: """Return the camera model.""" return MODELS[self._model] @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" camera = self._data.get_camera(self._id) self._vpnurl, self._localurl = self._data.camera_urls(self._id) @@ -273,7 +277,20 @@ class NetatmoCamera(NetatmoBase, Camera): self._data.outdoor_events.get(self._id, {}) ) - def process_events(self, events): + self._attr_extra_state_attributes.update( + { + "id": self._id, + "status": self._status, + "sd_status": self._sd_status, + "alim_status": self._alim_status, + "is_local": self._is_local, + "vpn_url": self._vpnurl, + "local_url": self._localurl, + "light_state": self._light_state, + } + ) + + def process_events(self, events: dict) -> dict: """Add meta data to events.""" for event in events.values(): if "video_id" not in event: @@ -288,9 +305,9 @@ class NetatmoCamera(NetatmoBase, Camera): ] = f"{self._vpnurl}/vod/{event['video_id']}/files/{self._quality}/index.m3u8" return events - async def _service_set_persons_home(self, **kwargs): + async def _service_set_persons_home(self, **kwargs: Any) -> None: """Service to change current home schedule.""" - persons = kwargs.get(ATTR_PERSONS) + persons = kwargs.get(ATTR_PERSONS, {}) person_ids = [] for person in persons: for pid, data in self._data.persons.items(): @@ -302,7 +319,7 @@ class NetatmoCamera(NetatmoBase, Camera): ) _LOGGER.debug("Set %s as at home", persons) - async def _service_set_person_away(self, **kwargs): + async def _service_set_person_away(self, **kwargs: Any) -> None: """Service to mark a person as away or set the home as empty.""" person = kwargs.get(ATTR_PERSON) person_id = None @@ -325,10 +342,10 @@ class NetatmoCamera(NetatmoBase, Camera): ) _LOGGER.debug("Set home as empty") - async def _service_set_camera_light(self, **kwargs): + async def _service_set_camera_light(self, **kwargs: Any) -> None: """Service to set light mode.""" - mode = kwargs.get(ATTR_CAMERA_LIGHT_MODE) - _LOGGER.debug("Turn %s camera light for '%s'", mode, self._name) + mode = str(kwargs.get(ATTR_CAMERA_LIGHT_MODE)) + _LOGGER.debug("Turn %s camera light for '%s'", mode, self._attr_name) await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index ce1eba11b70..ccc5816a28b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import cast import pyatmo import voluptuous as vol @@ -19,6 +20,7 @@ from homeassistant.components.climate.const import ( SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, @@ -26,11 +28,13 @@ from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import async_get_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_HEATING_POWER_REQUEST, @@ -49,7 +53,11 @@ from .const import ( SERVICE_SET_SCHEDULE, SIGNAL_NAME, ) -from .data_handler import HOMEDATA_DATA_CLASS_NAME, HOMESTATUS_DATA_CLASS_NAME +from .data_handler import ( + HOMEDATA_DATA_CLASS_NAME, + HOMESTATUS_DATA_CLASS_NAME, + NetatmoDataHandler, +) from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) @@ -106,8 +114,12 @@ DEFAULT_MAX_TEMP = 30 NA_THERM = "NATherm1" NA_VALVE = "NRV" +SUGGESTED_AREA = "suggested_area" -async def async_setup_entry(hass, entry, async_add_entities): + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Netatmo energy platform.""" data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] @@ -163,7 +175,9 @@ async def async_setup_entry(hass, entry, async_add_entities): class NetatmoThermostat(NetatmoBase, ClimateEntity): """Representation a Netatmo thermostat.""" - def __init__(self, data_handler, home_id, room_id): + def __init__( + self, data_handler: NetatmoDataHandler, home_id: str, room_id: str + ) -> None: """Initialize the sensor.""" ClimateEntity.__init__(self) super().__init__(data_handler) @@ -189,36 +203,36 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._home_status = self.data_handler.data[self._home_status_class] self._room_status = self._home_status.rooms[room_id] - self._room_data = self._data.rooms[home_id][room_id] + self._room_data: dict = self._data.rooms[home_id][room_id] - self._model = NA_VALVE - for module in self._room_data.get("module_ids"): + self._model: str = NA_VALVE + for module in self._room_data.get("module_ids", []): if self._home_status.thermostats.get(module): self._model = NA_THERM break self._device_name = self._data.rooms[home_id][room_id]["name"] - self._name = f"{MANUFACTURER} {self._device_name}" - self._current_temperature = None - self._target_temperature = None - self._preset = None - self._away = None + self._attr_name = f"{MANUFACTURER} {self._device_name}" + self._current_temperature: float | None = None + self._target_temperature: float | None = None + self._preset: str | None = None + self._away: bool | None = None self._operation_list = [HVAC_MODE_AUTO, HVAC_MODE_HEAT] self._support_flags = SUPPORT_FLAGS - self._hvac_mode = None + self._hvac_mode: str = HVAC_MODE_AUTO self._battery_level = None - self._connected = None + self._connected: bool | None = None - self._away_temperature = None - self._hg_temperature = None - self._boilerstatus = None + self._away_temperature: float | None = None + self._hg_temperature: float | None = None + self._boilerstatus: bool | None = None self._setpoint_duration = None self._selected_schedule = None if self._model == NA_THERM: self._operation_list.append(HVAC_MODE_OFF) - self._unique_id = f"{self._id}-{self._model}" + self._attr_unique_id = f"{self._id}-{self._model}" async def async_added_to_hass(self) -> None: """Entity created.""" @@ -240,9 +254,10 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): registry = await async_get_registry(self.hass) device = registry.async_get_device({(DOMAIN, self._id)}, set()) + assert device self.hass.data[DOMAIN][DATA_DEVICE_IDS][self._home_id] = device.id - async def handle_event(self, event): + async def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -253,6 +268,9 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ self._home_id ].get(data["schedule_id"]) + self._attr_extra_state_attributes.update( + {"selected_schedule": self._selected_schedule} + ) self.async_write_ha_state() self.data_handler.async_force_update(self._home_status_class) return @@ -304,22 +322,29 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return @property - def supported_features(self): + def _data(self) -> pyatmo.AsyncHomeData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncHomeData, self.data_handler.data[self._data_classes[0]["name"]] + ) + + @property + def supported_features(self) -> int: """Return the list of supported features.""" return self._support_flags @property - def temperature_unit(self): + def temperature_unit(self) -> str: """Return the unit of measurement.""" return TEMP_CELSIUS @property - def current_temperature(self): + def current_temperature(self) -> float | None: """Return the current temperature.""" return self._current_temperature @property - def target_temperature(self): + def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" return self._target_temperature @@ -329,12 +354,12 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return PRECISION_HALVES @property - def hvac_mode(self): + def hvac_mode(self) -> str: """Return hvac operation ie. heat, cool mode.""" return self._hvac_mode @property - def hvac_modes(self): + def hvac_modes(self) -> list[str]: """Return the list of available hvac operation modes.""" return self._operation_list @@ -415,7 +440,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): """Return a list of available preset modes.""" return SUPPORT_PRESET - async def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: dict) -> None: """Set new target temperature for 2 hours.""" temp = kwargs.get(ATTR_TEMPERATURE) if temp is None: @@ -426,25 +451,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self.async_write_ha_state() - @property - def extra_state_attributes(self): - """Return the state attributes of the thermostat.""" - attr = {} - - if self._battery_level is not None: - attr[ATTR_BATTERY_LEVEL] = self._battery_level - - if self._model == NA_VALVE: - attr[ATTR_HEATING_POWER_REQUEST] = self._room_status.get( - "heating_power_request", 0 - ) - - if self._selected_schedule is not None: - attr[ATTR_SELECTED_SCHEDULE] = self._selected_schedule - - return attr - - async def async_turn_off(self): + async def async_turn_off(self) -> None: """Turn the entity off.""" if self._model == NA_VALVE: await self._home_status.async_set_room_thermpoint( @@ -458,7 +465,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) self.async_write_ha_state() - async def async_turn_on(self): + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._home_status.async_set_room_thermpoint(self._id, STATE_NETATMO_HOME) self.async_write_ha_state() @@ -469,7 +476,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return bool(self._connected) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" self._home_status = self.data_handler.data[self._home_status_class] if self._home_status is None: @@ -502,8 +509,6 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): if "current_temperature" not in roomstatus: return - if self._model is None: - self._model = roomstatus["module_type"] self._current_temperature = roomstatus["current_temperature"] self._target_temperature = roomstatus["target_temperature"] self._preset = NETATMO_MAP_PRESET[roomstatus["setpoint_mode"]] @@ -513,7 +518,20 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): self._away = self._hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - def _build_room_status(self): + if self._battery_level is not None: + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = self._battery_level + + if self._model == NA_VALVE: + self._attr_extra_state_attributes[ + ATTR_HEATING_POWER_REQUEST + ] = self._room_status.get("heating_power_request", 0) + + if self._selected_schedule is not None: + self._attr_extra_state_attributes[ + ATTR_SELECTED_SCHEDULE + ] = self._selected_schedule + + def _build_room_status(self) -> dict: """Construct room status.""" try: roomstatus = { @@ -572,7 +590,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): return {} - async def _async_service_set_schedule(self, **kwargs): + async def _async_service_set_schedule(self, **kwargs: dict) -> None: schedule_name = kwargs.get(ATTR_SCHEDULE_NAME) schedule_id = None for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): @@ -594,12 +612,14 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): ) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" - return {**super().device_info, "suggested_area": self._room_data["name"]} + device_info: DeviceInfo = super().device_info + device_info["suggested_area"] = self._room_data["name"] + return device_info -def get_all_home_ids(home_data: pyatmo.HomeData) -> list[str]: +def get_all_home_ids(home_data: pyatmo.HomeData | None) -> list[str]: """Get all the home ids returned by NetAtmo API.""" if home_data is None: return [] diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index ea44339b99f..9b7c3376076 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Netatmo.""" +from __future__ import annotations + import logging import uuid @@ -7,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from .const import ( @@ -32,7 +35,9 @@ class NetatmoFlowHandler( @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return NetatmoOptionsFlowHandler(config_entry) @@ -62,7 +67,7 @@ class NetatmoFlowHandler( return {"scope": " ".join(scopes)} - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Handle a flow start.""" await self.async_set_unique_id(DOMAIN) @@ -81,17 +86,19 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input: dict | None = None) -> FlowResult: """Manage the Netatmo options.""" return await self.async_step_public_weather_areas() - async def async_step_public_weather_areas(self, user_input=None): + async def async_step_public_weather_areas( + self, user_input: dict | None = None + ) -> FlowResult: """Manage configuration of Netatmo public weather areas.""" - errors = {} + errors: dict = {} if user_input is not None: new_client = user_input.pop(CONF_NEW_AREA, None) - areas = user_input.pop(CONF_WEATHER_AREAS, None) + areas = user_input.pop(CONF_WEATHER_AREAS, []) user_input[CONF_WEATHER_AREAS] = { area: self.options[CONF_WEATHER_AREAS][area] for area in areas } @@ -110,7 +117,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_WEATHER_AREAS, default=weather_areas, - ): cv.multi_select(weather_areas), + ): cv.multi_select({wa: None for wa in weather_areas}), vol.Optional(CONF_NEW_AREA): str, } ) @@ -120,7 +127,7 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): errors=errors, ) - async def async_step_public_weather(self, user_input=None): + async def async_step_public_weather(self, user_input: dict) -> FlowResult: """Manage configuration of Netatmo public weather sensors.""" if user_input is not None and CONF_NEW_AREA not in user_input: self.options[CONF_WEATHER_AREAS][ @@ -181,17 +188,17 @@ class NetatmoOptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form(step_id="public_weather", data_schema=data_schema) - def _create_options_entry(self): + def _create_options_entry(self) -> FlowResult: """Update config entry options.""" return self.async_create_entry( title="Netatmo Public Weather", data=self.options ) -def fix_coordinates(user_input): +def fix_coordinates(user_input: dict) -> dict: """Fix coordinates if they don't comply with the Netatmo API.""" # Ensure coordinates have acceptable length for the Netatmo API - for coordinate in [CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW]: + for coordinate in (CONF_LAT_NE, CONF_LAT_SW, CONF_LON_NE, CONF_LON_SW): if len(str(user_input[coordinate]).split(".")[1]) < 7: user_input[coordinate] = user_input[coordinate] + 0.0000001 diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 2f840baa4c3..ea0f486b6cc 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -2,14 +2,16 @@ from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN API = "api" DOMAIN = "netatmo" MANUFACTURER = "Netatmo" +DEFAULT_ATTRIBUTION = f"Data provided by {MANUFACTURER}" -PLATFORMS = [CAMERA_DOMAIN, CLIMATE_DOMAIN, LIGHT_DOMAIN, SENSOR_DOMAIN] +PLATFORMS = [CAMERA_DOMAIN, CLIMATE_DOMAIN, LIGHT_DOMAIN, SELECT_DOMAIN, SENSOR_DOMAIN] MODEL_NAPLUG = "Relay" MODEL_NATHERM1 = "Smart Thermostat" @@ -74,7 +76,7 @@ DATA_SCHEDULES = "netatmo_schedules" NETATMO_WEBHOOK_URL = None NETATMO_EVENT = "netatmo_event" -DEFAULT_PERSON = "Unknown" +DEFAULT_PERSON = "unknown" DEFAULT_DISCOVERY = True DEFAULT_WEBHOOKS = False diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index e93e602d6a7..128a3174b9d 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio from collections import deque +from dataclasses import dataclass from datetime import timedelta from itertools import islice import logging from time import time +from typing import Any import pyatmo @@ -34,8 +36,6 @@ HOMEDATA_DATA_CLASS_NAME = "AsyncHomeData" HOMESTATUS_DATA_CLASS_NAME = "AsyncHomeStatus" PUBLICDATA_DATA_CLASS_NAME = "AsyncPublicData" -NEXT_SCAN = "next_scan" - DATA_CLASSES = { WEATHERSTATION_DATA_CLASS_NAME: pyatmo.AsyncWeatherStationData, HOMECOACH_DATA_CLASS_NAME: pyatmo.AsyncHomeCoachData, @@ -57,6 +57,16 @@ DEFAULT_INTERVALS = { SCAN_INTERVAL = 60 +@dataclass +class NetatmoDataClass: + """Class for keeping track of Netatmo data class metadata.""" + + name: str + interval: int + next_scan: float + subscriptions: list[CALLBACK_TYPE] + + class NetatmoDataHandler: """Manages the Netatmo data handling.""" @@ -66,11 +76,11 @@ class NetatmoDataHandler: self._auth = hass.data[DOMAIN][entry.entry_id][AUTH] self.listeners: list[CALLBACK_TYPE] = [] self.data_classes: dict = {} - self.data = {} - self._queue = deque() + self.data: dict = {} + self._queue: deque = deque() self._webhook: bool = False - async def async_setup(self): + async def async_setup(self) -> None: """Set up the Netatmo data handler.""" async_track_time_interval( @@ -85,7 +95,7 @@ class NetatmoDataHandler: ) ) - async def async_update(self, event_time): + async def async_update(self, event_time: timedelta) -> None: """ Update device. @@ -93,12 +103,12 @@ class NetatmoDataHandler: to minimize the calls on the api service. """ for data_class in islice(self._queue, 0, BATCH_SIZE): - if data_class[NEXT_SCAN] > time(): + if data_class.next_scan > time(): continue - if data_class_name := data_class["name"]: - self.data_classes[data_class_name][NEXT_SCAN] = ( - time() + data_class["interval"] + if data_class_name := data_class.name: + self.data_classes[data_class_name].next_scan = ( + time() + data_class.interval ) await self.async_fetch_data(data_class_name) @@ -106,17 +116,17 @@ class NetatmoDataHandler: self._queue.rotate(BATCH_SIZE) @callback - def async_force_update(self, data_class_entry): + def async_force_update(self, data_class_entry: str) -> None: """Prioritize data retrieval for given data class entry.""" - self.data_classes[data_class_entry][NEXT_SCAN] = time() + self.data_classes[data_class_entry].next_scan = time() self._queue.rotate(-(self._queue.index(self.data_classes[data_class_entry]))) - async def async_cleanup(self): + async def async_cleanup(self) -> None: """Clean up the Netatmo data handler.""" for listener in self.listeners: listener() - async def handle_event(self, event): + async def handle_event(self, event: dict) -> None: """Handle webhook events.""" if event["data"][WEBHOOK_PUSH_TYPE] == WEBHOOK_ACTIVATION: _LOGGER.info("%s webhook successfully registered", MANUFACTURER) @@ -130,7 +140,7 @@ class NetatmoDataHandler: _LOGGER.debug("%s camera reconnected", MANUFACTURER) self.async_force_update(CAMERA_DATA_CLASS_NAME) - async def async_fetch_data(self, data_class_entry): + async def async_fetch_data(self, data_class_entry: str) -> None: """Fetch data and notify.""" if self.data[data_class_entry] is None: return @@ -149,30 +159,31 @@ class NetatmoDataHandler: _LOGGER.debug(err) return - for update_callback in self.data_classes[data_class_entry]["subscriptions"]: + for update_callback in self.data_classes[data_class_entry].subscriptions: if update_callback: update_callback() async def register_data_class( - self, data_class_name, data_class_entry, update_callback, **kwargs - ): + self, + data_class_name: str, + data_class_entry: str, + update_callback: CALLBACK_TYPE, + **kwargs: Any, + ) -> None: """Register data class.""" if data_class_entry in self.data_classes: - if ( - update_callback - not in self.data_classes[data_class_entry]["subscriptions"] - ): - self.data_classes[data_class_entry]["subscriptions"].append( + if update_callback not in self.data_classes[data_class_entry].subscriptions: + self.data_classes[data_class_entry].subscriptions.append( update_callback ) return - self.data_classes[data_class_entry] = { - "name": data_class_entry, - "interval": DEFAULT_INTERVALS[data_class_name], - NEXT_SCAN: time() + DEFAULT_INTERVALS[data_class_name], - "subscriptions": [update_callback], - } + self.data_classes[data_class_entry] = NetatmoDataClass( + name=data_class_entry, + interval=DEFAULT_INTERVALS[data_class_name], + next_scan=time() + DEFAULT_INTERVALS[data_class_name], + subscriptions=[update_callback], + ) self.data[data_class_entry] = DATA_CLASSES[data_class_name]( self._auth, **kwargs @@ -183,11 +194,13 @@ class NetatmoDataHandler: self._queue.append(self.data_classes[data_class_entry]) _LOGGER.debug("Data class %s added", data_class_entry) - async def unregister_data_class(self, data_class_entry, update_callback): + async def unregister_data_class( + self, data_class_entry: str, update_callback: CALLBACK_TYPE | None + ) -> None: """Unregister data class.""" - self.data_classes[data_class_entry]["subscriptions"].remove(update_callback) + self.data_classes[data_class_entry].subscriptions.remove(update_callback) - if not self.data_classes[data_class_entry].get("subscriptions"): + if not self.data_classes[data_class_entry].subscriptions: self._queue.remove(self.data_classes[data_class_entry]) self.data_classes.pop(data_class_entry) self.data.pop(data_class_entry) diff --git a/homeassistant/components/netatmo/device_trigger.py b/homeassistant/components/netatmo/device_trigger.py index b0d4e18b7c9..1bfc736d581 100644 --- a/homeassistant/components/netatmo/device_trigger.py +++ b/homeassistant/components/netatmo/device_trigger.py @@ -63,7 +63,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: """Validate config.""" config = TRIGGER_SCHEMA(config) @@ -129,10 +131,10 @@ async def async_attach_trigger( device = device_registry.async_get(config[CONF_DEVICE_ID]) if not device: - return + return lambda: None if device.model not in DEVICES: - return + return lambda: None event_config = { event_trigger.CONF_PLATFORM: "event", @@ -142,10 +144,11 @@ async def async_attach_trigger( ATTR_DEVICE_ID: config[ATTR_DEVICE_ID], }, } + if config[CONF_TYPE] in SUBTYPES: - event_config[event_trigger.CONF_EVENT_DATA]["data"] = { - "mode": config[CONF_SUBTYPE] - } + event_config.update( + {event_trigger.CONF_EVENT_DATA: {"data": {"mode": config[CONF_SUBTYPE]}}} + ) event_config = event_trigger.TRIGGER_SCHEMA(event_config) return await event_trigger.async_attach_trigger( diff --git a/homeassistant/components/netatmo/helper.py b/homeassistant/components/netatmo/helper.py index d9ef4d1e455..7e8f32817dd 100644 --- a/homeassistant/components/netatmo/helper.py +++ b/homeassistant/components/netatmo/helper.py @@ -1,6 +1,6 @@ """Helper for Netatmo integration.""" from dataclasses import dataclass -from uuid import uuid4 +from uuid import UUID, uuid4 @dataclass @@ -14,4 +14,4 @@ class NetatmoArea: lon_sw: float mode: str show_on_map: bool - uuid: str = uuid4() + uuid: UUID = uuid4() diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 160fb00be6b..6fe5e84e65a 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -1,10 +1,17 @@ """Support for the Netatmo camera lights.""" +from __future__ import annotations + import logging +from typing import cast + +import pyatmo from homeassistant.components.light import LightEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DATA_HANDLER, @@ -21,7 +28,9 @@ from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Netatmo camera light platform.""" if "access_camera" not in entry.data["token"]["scope"]: _LOGGER.info( @@ -79,10 +88,10 @@ class NetatmoLight(NetatmoBase, LightEntity): self._id = camera_id self._home_id = home_id self._model = camera_type - self._device_name = self._data.get_camera(camera_id).get("name") - self._name = f"{MANUFACTURER} {self._device_name}" + self._device_name: str = self._data.get_camera(camera_id)["name"] + self._attr_name = f"{MANUFACTURER} {self._device_name}" self._is_on = False - self._unique_id = f"{self._id}-light" + self._attr_unique_id = f"{self._id}-light" async def async_added_to_hass(self) -> None: """Entity created.""" @@ -97,7 +106,7 @@ class NetatmoLight(NetatmoBase, LightEntity): ) @callback - def handle_event(self, event): + def handle_event(self, event: dict) -> None: """Handle webhook events.""" data = event["data"] @@ -114,28 +123,36 @@ class NetatmoLight(NetatmoBase, LightEntity): self.async_write_ha_state() return + @property + def _data(self) -> pyatmo.AsyncCameraData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncCameraData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + @property def available(self) -> bool: """If the webhook is not established, mark as unavailable.""" return bool(self.data_handler.webhook) @property - def is_on(self): + def is_on(self) -> bool: """Return true if light is on.""" return self._is_on - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: dict) -> None: """Turn camera floodlight on.""" - _LOGGER.debug("Turn camera '%s' on", self._name) + _LOGGER.debug("Turn camera '%s' on", self.name) await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, floodlight="on", ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: dict) -> None: """Turn camera floodlight into auto mode.""" - _LOGGER.debug("Turn camera '%s' to auto mode", self._name) + _LOGGER.debug("Turn camera '%s' to auto mode", self.name) await self._data.async_set_state( home_id=self._home_id, camera_id=self._id, @@ -143,6 +160,6 @@ class NetatmoLight(NetatmoBase, LightEntity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" self._is_on = bool(self._data.get_light_state(self._id) == "on") diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index de7fbc36038..6c99f3c0786 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==5.2.0" + "pyatmo==5.2.3" ], "after_dependencies": [ "cloud", diff --git a/homeassistant/components/netatmo/media_source.py b/homeassistant/components/netatmo/media_source.py index 99f52d95ad4..d80225c0368 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -11,7 +11,6 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_VIDEO, ) from homeassistant.components.media_player.errors import BrowseError -from homeassistant.components.media_source.const import MEDIA_MIME_TYPES from homeassistant.components.media_source.error import MediaSourceError, Unresolvable from homeassistant.components.media_source.models import ( BrowseMediaSource, @@ -31,7 +30,7 @@ class IncompatibleMediaSource(MediaSourceError): """Incompatible media source attributes.""" -async def async_get_media_source(hass: HomeAssistant): +async def async_get_media_source(hass: HomeAssistant) -> NetatmoSource: """Set up Netatmo media source.""" return NetatmoSource(hass) @@ -54,7 +53,9 @@ class NetatmoSource(MediaSource): return PlayMedia(url, MIME_TYPE) async def async_browse_media( - self, item: MediaSourceItem, media_types: tuple[str] = MEDIA_MIME_TYPES + self, + item: MediaSourceItem, + media_types: tuple[str] = ("video",), ) -> BrowseMediaSource: """Return media.""" try: @@ -65,7 +66,7 @@ class NetatmoSource(MediaSource): return self._browse_media(source, camera_id, event_id) def _browse_media( - self, source: str, camera_id: str, event_id: int + self, source: str, camera_id: str, event_id: int | None ) -> BrowseMediaSource: """Browse media.""" if camera_id and camera_id not in self.events: @@ -77,7 +78,7 @@ class NetatmoSource(MediaSource): return self._build_item_response(source, camera_id, event_id) def _build_item_response( - self, source: str, camera_id: str, event_id: int = None + self, source: str, camera_id: str, event_id: int | None = None ) -> BrowseMediaSource: if event_id and event_id in self.events[camera_id]: created = dt.datetime.fromtimestamp(event_id) @@ -148,7 +149,7 @@ class NetatmoSource(MediaSource): return media -def remove_html_tags(text): +def remove_html_tags(text: str) -> str: """Remove html tags from string.""" clean = re.compile("<.*?>") return re.sub(clean, "", text) diff --git a/homeassistant/components/netatmo/netatmo_entity_base.py b/homeassistant/components/netatmo/netatmo_entity_base.py index 5d43a46e89b..f276fb3d947 100644 --- a/homeassistant/components/netatmo/netatmo_entity_base.py +++ b/homeassistant/components/netatmo/netatmo_entity_base.py @@ -1,10 +1,18 @@ """Base class for Netatmo entities.""" from __future__ import annotations +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import DATA_DEVICE_IDS, DOMAIN, MANUFACTURER, MODELS, SIGNAL_NAME +from .const import ( + DATA_DEVICE_IDS, + DEFAULT_ATTRIBUTION, + DOMAIN, + MANUFACTURER, + MODELS, + SIGNAL_NAME, +) from .data_handler import PUBLICDATA_DATA_CLASS_NAME, NetatmoDataHandler @@ -17,11 +25,12 @@ class NetatmoBase(Entity): self._data_classes: list[dict] = [] self._listeners: list[CALLBACK_TYPE] = [] - self._device_name = None - self._id = None - self._model = None - self._name = None - self._unique_id = None + self._device_name: str = "" + self._id: str = "" + self._model: str = "" + self._attr_name = None + self._attr_unique_id = None + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} async def async_added_to_hass(self) -> None: """Entity created.""" @@ -52,7 +61,7 @@ class NetatmoBase(Entity): data_class["name"], signal_name, self.async_update_callback ) - for sub in self.data_handler.data_classes[signal_name].get("subscriptions"): + for sub in self.data_handler.data_classes[signal_name].subscriptions: if sub is None: await self.data_handler.unregister_data_class(signal_name, None) @@ -62,7 +71,7 @@ class NetatmoBase(Entity): self.async_update_callback() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" await super().async_will_remove_from_hass() @@ -75,27 +84,12 @@ class NetatmoBase(Entity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" raise NotImplementedError @property - def _data(self): - """Return data for this entity.""" - return self.data_handler.data[self._data_classes[0]["name"]] - - @property - def unique_id(self): - """Return the unique ID of this entity.""" - return self._unique_id - - @property - def name(self): - """Return the name of this entity.""" - return self._name - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return the device info for the sensor.""" return { "identifiers": {(DOMAIN, self._id)}, diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py new file mode 100644 index 00000000000..718d7e440b9 --- /dev/null +++ b/homeassistant/components/netatmo/select.py @@ -0,0 +1,161 @@ +"""Support for the Netatmo climate schedule selector.""" +from __future__ import annotations + +import logging +from typing import cast + +import pyatmo + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .climate import get_all_home_ids +from .const import ( + DATA_HANDLER, + DATA_SCHEDULES, + DOMAIN, + EVENT_TYPE_SCHEDULE, + MANUFACTURER, + SIGNAL_NAME, +) +from .data_handler import HOMEDATA_DATA_CLASS_NAME, NetatmoDataHandler +from .netatmo_entity_base import NetatmoBase + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Netatmo energy platform schedule selector.""" + data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + + await data_handler.register_data_class( + HOMEDATA_DATA_CLASS_NAME, HOMEDATA_DATA_CLASS_NAME, None + ) + home_data = data_handler.data.get(HOMEDATA_DATA_CLASS_NAME) + + if not home_data or home_data.raw_data == {}: + raise PlatformNotReady + + if HOMEDATA_DATA_CLASS_NAME not in data_handler.data: + raise PlatformNotReady + + entities = [ + NetatmoScheduleSelect( + data_handler, + home_id, + list(hass.data[DOMAIN][DATA_SCHEDULES][home_id].values()), + ) + for home_id in get_all_home_ids(home_data) + if home_id in hass.data[DOMAIN][DATA_SCHEDULES] + ] + + _LOGGER.debug("Adding climate schedule select entities %s", entities) + async_add_entities(entities, True) + + +class NetatmoScheduleSelect(NetatmoBase, SelectEntity): + """Representation a Netatmo thermostat schedule selector.""" + + def __init__( + self, data_handler: NetatmoDataHandler, home_id: str, options: list + ) -> None: + """Initialize the select entity.""" + SelectEntity.__init__(self) + super().__init__(data_handler) + + self._home_id = home_id + + self._data_classes.extend( + [ + { + "name": HOMEDATA_DATA_CLASS_NAME, + SIGNAL_NAME: HOMEDATA_DATA_CLASS_NAME, + }, + ] + ) + + self._device_name = self._data.homes[home_id]["name"] + self._attr_name = f"{MANUFACTURER} {self._device_name}" + + self._model: str = "NATherm1" + + self._attr_unique_id = f"{self._home_id}-schedule-select" + + self._attr_current_option = self._data._get_selected_schedule( + home_id=self._home_id + ).get("name") + self._attr_options = options + + async def async_added_to_hass(self) -> None: + """Entity created.""" + await super().async_added_to_hass() + + for event_type in (EVENT_TYPE_SCHEDULE,): + self._listeners.append( + async_dispatcher_connect( + self.hass, + f"signal-{DOMAIN}-webhook-{event_type}", + self.handle_event, + ) + ) + + async def handle_event(self, event: dict) -> None: + """Handle webhook events.""" + data = event["data"] + + if self._home_id != data["home_id"]: + return + + if data["event_type"] == EVENT_TYPE_SCHEDULE and "schedule_id" in data: + self._attr_current_option = self.hass.data[DOMAIN][DATA_SCHEDULES][ + self._home_id + ].get(data["schedule_id"]) + self.async_write_ha_state() + + @property + def _data(self) -> pyatmo.AsyncHomeData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncHomeData, + self.data_handler.data[self._data_classes[0]["name"]], + ) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + for sid, name in self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].items(): + if name != option: + continue + _LOGGER.debug( + "Setting %s schedule to %s (%s)", + self._home_id, + option, + sid, + ) + await self._data.async_switch_home_schedule( + home_id=self._home_id, schedule_id=sid + ) + break + + @callback + def async_update_callback(self) -> None: + """Update the entity's state.""" + self._attr_current_option = ( + self._data._get_selected_schedule( # pylint: disable=protected-access + home_id=self._home_id + ).get("name") + ) + self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id] = { + schedule_id: schedule_data.get("name") + for schedule_id, schedule_data in ( + self._data.schedules[self._home_id].items() + ) + } + self._attr_options = list( + self.hass.data[DOMAIN][DATA_SCHEDULES][self._home_id].values() + ) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 2dbbeb56c76..14128aefa6a 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -1,7 +1,14 @@ """Support for the Netatmo Weather Service.""" -import logging +from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from dataclasses import dataclass +import logging +from typing import NamedTuple, cast + +import pyatmo + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -17,29 +24,32 @@ from homeassistant.const import ( PERCENTAGE, PRESSURE_MBAR, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + SOUND_PRESSURE_DB, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import async_entries_for_config_entry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import CONF_WEATHER_AREAS, DATA_HANDLER, DOMAIN, MANUFACTURER, SIGNAL_NAME from .data_handler import ( HOMECOACH_DATA_CLASS_NAME, PUBLICDATA_DATA_CLASS_NAME, WEATHERSTATION_DATA_CLASS_NAME, + NetatmoDataHandler, ) from .helper import NetatmoArea from .netatmo_entity_base import NetatmoBase _LOGGER = logging.getLogger(__name__) -SUPPORTED_PUBLIC_SENSOR_TYPES = [ +SUPPORTED_PUBLIC_SENSOR_TYPES: tuple[str, ...] = ( "temperature", "pressure", "humidity", @@ -48,91 +58,254 @@ SUPPORTED_PUBLIC_SENSOR_TYPES = [ "guststrength", "sum_rain_1", "sum_rain_24", -] +) -SENSOR_TYPES = { - "temperature": ["Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE, True], - "temp_trend": ["Temperature trend", None, "mdi:trending-up", None, False], - "co2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, None, DEVICE_CLASS_CO2, True], - "pressure": ["Pressure", PRESSURE_MBAR, None, DEVICE_CLASS_PRESSURE, True], - "pressure_trend": ["Pressure trend", None, "mdi:trending-up", None, False], - "noise": ["Noise", "dB", "mdi:volume-high", None, True], - "humidity": ["Humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY, True], - "rain": ["Rain", LENGTH_MILLIMETERS, "mdi:weather-rainy", None, True], - "sum_rain_1": [ - "Rain last hour", - LENGTH_MILLIMETERS, - "mdi:weather-rainy", - None, - False, - ], - "sum_rain_24": ["Rain today", LENGTH_MILLIMETERS, "mdi:weather-rainy", None, True], - "battery_percent": [ - "Battery Percent", - PERCENTAGE, - None, - DEVICE_CLASS_BATTERY, - True, - ], - "windangle": ["Direction", None, "mdi:compass-outline", None, True], - "windangle_value": ["Angle", DEGREE, "mdi:compass-outline", None, False], - "windstrength": [ - "Wind Strength", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - True, - ], - "gustangle": ["Gust Direction", None, "mdi:compass-outline", None, False], - "gustangle_value": ["Gust Angle", DEGREE, "mdi:compass-outline", None, False], - "guststrength": [ - "Gust Strength", - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - None, - False, - ], - "reachable": ["Reachability", None, "mdi:signal", None, False], - "rf_status": ["Radio", None, "mdi:signal", None, False], - "rf_status_lvl": [ - "Radio Level", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - False, - ], - "wifi_status": ["Wifi", None, "mdi:wifi", None, False], - "wifi_status_lvl": [ - "Wifi Level", - SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - False, - ], - "health_idx": ["Health", None, "mdi:cloud", None, True], -} + +@dataclass +class NetatmoRequiredKeysMixin: + """Mixin for required keys.""" + + netatmo_name: str + + +@dataclass +class NetatmoSensorEntityDescription(SensorEntityDescription, NetatmoRequiredKeysMixin): + """Describes Netatmo sensor entity.""" + + +SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( + NetatmoSensorEntityDescription( + key="temperature", + name="Temperature", + netatmo_name="Temperature", + entity_registry_enabled_default=True, + unit_of_measurement=TEMP_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + NetatmoSensorEntityDescription( + key="temp_trend", + name="Temperature trend", + netatmo_name="temp_trend", + entity_registry_enabled_default=False, + icon="mdi:trending-up", + ), + NetatmoSensorEntityDescription( + key="co2", + name="CO2", + netatmo_name="CO2", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + entity_registry_enabled_default=True, + device_class=DEVICE_CLASS_CO2, + ), + NetatmoSensorEntityDescription( + key="pressure", + name="Pressure", + netatmo_name="Pressure", + entity_registry_enabled_default=True, + unit_of_measurement=PRESSURE_MBAR, + device_class=DEVICE_CLASS_PRESSURE, + ), + NetatmoSensorEntityDescription( + key="pressure_trend", + name="Pressure trend", + netatmo_name="pressure_trend", + entity_registry_enabled_default=False, + icon="mdi:trending-up", + ), + NetatmoSensorEntityDescription( + key="noise", + name="Noise", + netatmo_name="Noise", + entity_registry_enabled_default=True, + unit_of_measurement=SOUND_PRESSURE_DB, + icon="mdi:volume-high", + ), + NetatmoSensorEntityDescription( + key="humidity", + name="Humidity", + netatmo_name="Humidity", + entity_registry_enabled_default=True, + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_HUMIDITY, + ), + NetatmoSensorEntityDescription( + key="rain", + name="Rain", + netatmo_name="Rain", + entity_registry_enabled_default=True, + unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + NetatmoSensorEntityDescription( + key="sum_rain_1", + name="Rain last hour", + netatmo_name="sum_rain_1", + entity_registry_enabled_default=False, + unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + NetatmoSensorEntityDescription( + key="sum_rain_24", + name="Rain today", + netatmo_name="sum_rain_24", + entity_registry_enabled_default=True, + unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + ), + NetatmoSensorEntityDescription( + key="battery_percent", + name="Battery Percent", + netatmo_name="battery_percent", + entity_registry_enabled_default=True, + unit_of_measurement=PERCENTAGE, + device_class=DEVICE_CLASS_BATTERY, + ), + NetatmoSensorEntityDescription( + key="windangle", + name="Direction", + netatmo_name="WindAngle", + entity_registry_enabled_default=True, + icon="mdi:compass-outline", + ), + NetatmoSensorEntityDescription( + key="windangle_value", + name="Angle", + netatmo_name="WindAngle", + entity_registry_enabled_default=False, + unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + NetatmoSensorEntityDescription( + key="windstrength", + name="Wind Strength", + netatmo_name="WindStrength", + entity_registry_enabled_default=True, + unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + NetatmoSensorEntityDescription( + key="gustangle", + name="Gust Direction", + netatmo_name="GustAngle", + entity_registry_enabled_default=False, + icon="mdi:compass-outline", + ), + NetatmoSensorEntityDescription( + key="gustangle_value", + name="Gust Angle", + netatmo_name="GustAngle", + entity_registry_enabled_default=False, + unit_of_measurement=DEGREE, + icon="mdi:compass-outline", + ), + NetatmoSensorEntityDescription( + key="guststrength", + name="Gust Strength", + netatmo_name="GustStrength", + entity_registry_enabled_default=False, + unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + icon="mdi:weather-windy", + ), + NetatmoSensorEntityDescription( + key="reachable", + name="Reachability", + netatmo_name="reachable", + entity_registry_enabled_default=False, + icon="mdi:signal", + ), + NetatmoSensorEntityDescription( + key="rf_status", + name="Radio", + netatmo_name="rf_status", + entity_registry_enabled_default=False, + icon="mdi:signal", + ), + NetatmoSensorEntityDescription( + key="rf_status_lvl", + name="Radio Level", + netatmo_name="rf_status", + entity_registry_enabled_default=False, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + NetatmoSensorEntityDescription( + key="wifi_status", + name="Wifi", + netatmo_name="wifi_status", + entity_registry_enabled_default=False, + icon="mdi:wifi", + ), + NetatmoSensorEntityDescription( + key="wifi_status_lvl", + name="Wifi Level", + netatmo_name="wifi_status", + entity_registry_enabled_default=False, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + NetatmoSensorEntityDescription( + key="health_idx", + name="Health", + netatmo_name="health_idx", + entity_registry_enabled_default=True, + icon="mdi:cloud", + ), +) +SENSOR_TYPES_KEYS = [desc.key for desc in SENSOR_TYPES] MODULE_TYPE_OUTDOOR = "NAModule1" MODULE_TYPE_WIND = "NAModule2" MODULE_TYPE_RAIN = "NAModule3" MODULE_TYPE_INDOOR = "NAModule4" + +class BatteryData(NamedTuple): + """Metadata for a batter.""" + + full: int + high: int + medium: int + low: int + + BATTERY_VALUES = { - MODULE_TYPE_WIND: {"Full": 5590, "High": 5180, "Medium": 4770, "Low": 4360}, - MODULE_TYPE_RAIN: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, - MODULE_TYPE_INDOOR: {"Full": 5500, "High": 5280, "Medium": 4920, "Low": 4560}, - MODULE_TYPE_OUTDOOR: {"Full": 5500, "High": 5000, "Medium": 4500, "Low": 4000}, + MODULE_TYPE_WIND: BatteryData( + full=5590, + high=5180, + medium=4770, + low=4360, + ), + MODULE_TYPE_RAIN: BatteryData( + full=5500, + high=5000, + medium=4500, + low=4000, + ), + MODULE_TYPE_INDOOR: BatteryData( + full=5500, + high=5280, + medium=4920, + low=4560, + ), + MODULE_TYPE_OUTDOOR: BatteryData( + full=5500, + high=5000, + medium=4500, + low=4000, + ), } PUBLIC = "public" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up the Netatmo weather and homecoach platform.""" data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] platform_not_ready = True - async def find_entities(data_class_name): + async def find_entities(data_class_name: str) -> list: """Find all entities.""" all_module_infos = {} data = data_handler.data @@ -160,26 +333,29 @@ async def async_setup_entry(hass, entry, async_add_entities): conditions = [ c.lower() for c in data_class.get_monitored_conditions(module_id=module["_id"]) - if c.lower() in SENSOR_TYPES + if c.lower() in SENSOR_TYPES_KEYS ] for condition in conditions: - if f"{condition}_value" in SENSOR_TYPES: + if f"{condition}_value" in SENSOR_TYPES_KEYS: conditions.append(f"{condition}_value") - elif f"{condition}_lvl" in SENSOR_TYPES: + elif f"{condition}_lvl" in SENSOR_TYPES_KEYS: conditions.append(f"{condition}_lvl") - for condition in conditions: - entities.append( - NetatmoSensor(data_handler, data_class_name, module, condition) - ) + entities.extend( + [ + NetatmoSensor(data_handler, data_class_name, module, description) + for description in SENSOR_TYPES + if description.key in conditions + ] + ) _LOGGER.debug("Adding weather sensors %s", entities) return entities - for data_class_name in [ + for data_class_name in ( WEATHERSTATION_DATA_CLASS_NAME, HOMECOACH_DATA_CLASS_NAME, - ]: + ): await data_handler.register_data_class(data_class_name, data_class_name, None) data_class = data_handler.data.get(data_class_name) @@ -190,7 +366,7 @@ async def async_setup_entry(hass, entry, async_add_entities): device_registry = await hass.helpers.device_registry.async_get_registry() - async def add_public_entities(update=True): + async def add_public_entities(update: bool = True) -> None: """Retrieve Netatmo public weather entities.""" entities = { device.name: device.id @@ -232,10 +408,13 @@ async def async_setup_entry(hass, entry, async_add_entities): nonlocal platform_not_ready platform_not_ready = False - for sensor_type in SUPPORTED_PUBLIC_SENSOR_TYPES: - new_entities.append( - NetatmoPublicSensor(data_handler, area, sensor_type) - ) + new_entities.extend( + [ + NetatmoPublicSensor(data_handler, area, description) + for description in SENSOR_TYPES + if description.key in SUPPORTED_PUBLIC_SENSOR_TYPES + ] + ) for device_id in entities.values(): device_registry.async_remove_device(device_id) @@ -256,9 +435,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class NetatmoSensor(NetatmoBase, SensorEntity): """Implementation of a Netatmo sensor.""" - def __init__(self, data_handler, data_class_name, module_info, sensor_type): + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + data_handler: NetatmoDataHandler, + data_class_name: str, + module_info: dict, + description: NetatmoSensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(data_handler) + self.entity_description = description self._data_classes.append( {"name": data_class_name, SIGNAL_NAME: data_class_name} @@ -282,124 +470,65 @@ class NetatmoSensor(NetatmoBase, SensorEntity): f"{module_info.get('module_name', device['type'])}" ) - self._name = ( - f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[sensor_type][0]}" - ) - self.type = sensor_type - self._state = None - self._device_class = SENSOR_TYPES[self.type][3] - self._icon = SENSOR_TYPES[self.type][2] - self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self._attr_name = f"{MANUFACTURER} {self._device_name} {description.name}" self._model = device["type"] - self._unique_id = f"{self._id}-{self.type}" - self._enabled_default = SENSOR_TYPES[self.type][4] + self._attr_unique_id = f"{self._id}-{description.key}" @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + def _data(self) -> pyatmo.AsyncWeatherStationData: + """Return data for this entity.""" + return cast( + pyatmo.AsyncWeatherStationData, + self.data_handler.data[self._data_classes[0]["name"]], + ) @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit_of_measurement - - @property - def available(self): + def available(self) -> bool: """Return entity availability.""" - return self._state is not None - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default + return self.state is not None @callback - def async_update_callback(self): # noqa: C901 + def async_update_callback(self) -> None: """Update the entity's state.""" - if self._data is None: - if self._state is None: - return - _LOGGER.warning("No data from update") - self._state = None - return - data = self._data.get_last_data(station_id=self._station_id, exclude=3600).get( self._id ) if data is None: - if self._state: + if self.state: _LOGGER.debug( "No data found for %s - %s (%s)", self.name, self._device_name, self._id, ) - self._state = None + self._attr_state = None return try: - if self.type == "temperature": - self._state = round(data["Temperature"], 1) - elif self.type == "temp_trend": - self._state = data["temp_trend"] - elif self.type == "humidity": - self._state = data["Humidity"] - elif self.type == "rain": - self._state = data["Rain"] - elif self.type == "sum_rain_1": - self._state = round(data["sum_rain_1"], 1) - elif self.type == "sum_rain_24": - self._state = data["sum_rain_24"] - elif self.type == "noise": - self._state = data["Noise"] - elif self.type == "co2": - self._state = data["CO2"] - elif self.type == "pressure": - self._state = round(data["Pressure"], 1) - elif self.type == "pressure_trend": - self._state = data["pressure_trend"] - elif self.type == "battery_percent": - self._state = data["battery_percent"] - elif self.type == "windangle_value": - self._state = fix_angle(data["WindAngle"]) - elif self.type == "windangle": - self._state = process_angle(fix_angle(data["WindAngle"])) - elif self.type == "windstrength": - self._state = data["WindStrength"] - elif self.type == "gustangle_value": - self._state = fix_angle(data["GustAngle"]) - elif self.type == "gustangle": - self._state = process_angle(fix_angle(data["GustAngle"])) - elif self.type == "guststrength": - self._state = data["GustStrength"] - elif self.type == "reachable": - self._state = data["reachable"] - elif self.type == "rf_status_lvl": - self._state = data["rf_status"] - elif self.type == "rf_status": - self._state = process_rf(data["rf_status"]) - elif self.type == "wifi_status_lvl": - self._state = data["wifi_status"] - elif self.type == "wifi_status": - self._state = process_wifi(data["wifi_status"]) - elif self.type == "health_idx": - self._state = process_health(data["health_idx"]) + state = data[self.entity_description.netatmo_name] + if self.entity_description.key in {"temperature", "pressure", "sum_rain_1"}: + self._attr_state = round(state, 1) + elif self.entity_description.key in {"windangle_value", "gustangle_value"}: + self._attr_state = fix_angle(state) + elif self.entity_description.key in {"windangle", "gustangle"}: + self._attr_state = process_angle(fix_angle(state)) + elif self.entity_description.key == "rf_status": + self._attr_state = process_rf(state) + elif self.entity_description.key == "wifi_status": + self._attr_state = process_wifi(state) + elif self.entity_description.key == "health_idx": + self._attr_state = process_health(state) + else: + self._attr_state = state except KeyError: - if self._state: - _LOGGER.debug("No %s data found for %s", self.type, self._device_name) - self._state = None + if self.state: + _LOGGER.debug( + "No %s data found for %s", + self.entity_description.key, + self._device_name, + ) + self._attr_state = None return self.async_write_ha_state() @@ -435,20 +564,20 @@ def process_angle(angle: int) -> str: def process_battery(data: int, model: str) -> str: """Process battery data and return string for display.""" - values = BATTERY_VALUES[model] + battery_data = BATTERY_VALUES[model] - if data >= values["Full"]: + if data >= battery_data.full: return "Full" - if data >= values["High"]: + if data >= battery_data.high: return "High" - if data >= values["Medium"]: + if data >= battery_data.medium: return "Medium" - if data >= values["Low"]: + if data >= battery_data.low: return "Low" return "Very Low" -def process_health(health): +def process_health(health: int) -> str: """Process health index and return string for display.""" if health == 0: return "Healthy" @@ -458,11 +587,10 @@ def process_health(health): return "Fair" if health == 3: return "Poor" - if health == 4: - return "Unhealthy" + return "Unhealthy" -def process_rf(strength): +def process_rf(strength: int) -> str: """Process wifi signal strength and return string for display.""" if strength >= 90: return "Low" @@ -473,7 +601,7 @@ def process_rf(strength): return "Full" -def process_wifi(strength): +def process_wifi(strength: int) -> str: """Process wifi signal strength and return string for display.""" if strength >= 86: return "Low" @@ -487,9 +615,17 @@ def process_wifi(strength): class NetatmoPublicSensor(NetatmoBase, SensorEntity): """Represent a single sensor in a Netatmo.""" - def __init__(self, data_handler, area, sensor_type): + entity_description: NetatmoSensorEntityDescription + + def __init__( + self, + data_handler: NetatmoDataHandler, + area: NetatmoArea, + description: NetatmoSensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(data_handler) + self.entity_description = description self._signal_name = f"{PUBLICDATA_DATA_CLASS_NAME}-{area.uuid}" @@ -505,65 +641,35 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): } ) - self.type = sensor_type self.area = area self._mode = area.mode self._area_name = area.area_name self._id = self._area_name self._device_name = f"{self._area_name}" - self._name = f"{MANUFACTURER} {self._device_name} {SENSOR_TYPES[self.type][0]}" - self._state = None - self._device_class = SENSOR_TYPES[self.type][3] - self._icon = SENSOR_TYPES[self.type][2] - self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self._attr_name = f"{MANUFACTURER} {self._device_name} {description.name}" self._show_on_map = area.show_on_map - self._unique_id = f"{self._device_name.replace(' ', '-')}-{self.type}" + self._attr_unique_id = ( + f"{self._device_name.replace(' ', '-')}-{description.key}" + ) self._model = PUBLIC - @property - def icon(self): - """Icon to use in the frontend.""" - return self._icon + self._attr_extra_state_attributes.update( + { + ATTR_LATITUDE: (self.area.lat_ne + self.area.lat_sw) / 2, + ATTR_LONGITUDE: (self.area.lon_ne + self.area.lon_sw) / 2, + } + ) @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def extra_state_attributes(self): - """Return the attributes of the device.""" - attrs = {} - - if self._show_on_map: - attrs[ATTR_LATITUDE] = (self.area.lat_ne + self.area.lat_sw) / 2 - attrs[ATTR_LONGITUDE] = (self.area.lon_ne + self.area.lon_sw) / 2 - - return attrs - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - @property - def available(self): - """Return True if entity is available.""" - return self._state is not None - - @property - def _data(self): - return self.data_handler.data[self._signal_name] + def _data(self) -> pyatmo.AsyncPublicData: + """Return data for this entity.""" + return cast(pyatmo.AsyncPublicData, self.data_handler.data[self._signal_name]) async def async_added_to_hass(self) -> None: """Entity created.""" await super().async_added_to_hass() + assert self.device_info and "name" in self.device_info self.data_handler.listeners.append( async_dispatcher_connect( self.hass, @@ -572,7 +678,7 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) ) - async def async_config_update_callback(self, area): + async def async_config_update_callback(self, area: NetatmoArea) -> None: """Update the entity's config.""" if self.area == area: return @@ -607,40 +713,43 @@ class NetatmoPublicSensor(NetatmoBase, SensorEntity): ) @callback - def async_update_callback(self): + def async_update_callback(self) -> None: """Update the entity's state.""" data = None - if self.type == "temperature": + if self.entity_description.key == "temperature": data = self._data.get_latest_temperatures() - elif self.type == "pressure": + elif self.entity_description.key == "pressure": data = self._data.get_latest_pressures() - elif self.type == "humidity": + elif self.entity_description.key == "humidity": data = self._data.get_latest_humidities() - elif self.type == "rain": + elif self.entity_description.key == "rain": data = self._data.get_latest_rain() - elif self.type == "sum_rain_1": + elif self.entity_description.key == "sum_rain_1": data = self._data.get_60_min_rain() - elif self.type == "sum_rain_24": + elif self.entity_description.key == "sum_rain_24": data = self._data.get_24_h_rain() - elif self.type == "windstrength": + elif self.entity_description.key == "windstrength": data = self._data.get_latest_wind_strengths() - elif self.type == "guststrength": + elif self.entity_description.key == "guststrength": data = self._data.get_latest_gust_strengths() if data is None: - if self._state is None: + if self.state is None: return _LOGGER.debug( - "No station provides %s data in the area %s", self.type, self._area_name + "No station provides %s data in the area %s", + self.entity_description.key, + self._area_name, ) - self._state = None + self._attr_state = None return if values := [x for x in data.values() if x is not None]: if self._mode == "avg": - self._state = round(sum(values) / len(values), 1) + self._attr_state = round(sum(values) / len(values), 1) elif self._mode == "max": - self._state = max(values) + self._attr_state = max(values) + self._attr_available = self.state is not None self.async_write_ha_state() diff --git a/homeassistant/components/netatmo/translations/ar.json b/homeassistant/components/netatmo/translations/ar.json new file mode 100644 index 00000000000..2555a59177b --- /dev/null +++ b/homeassistant/components/netatmo/translations/ar.json @@ -0,0 +1,27 @@ +{ + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "\u0627\u0633\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629", + "lat_ne": " \u0627\u0644\u0632\u0627\u0648\u064a\u0629 \u0627\u0644\u0634\u0645\u0627\u0644\u064a\u0629 \u0627\u0644\u0634\u0631\u0642\u064a\u0629", + "lat_sw": " \u0627\u0644\u0632\u0627\u0648\u064a\u0629 \u0627\u0644\u062c\u0646\u0648\u0628\u064a\u0629 \u0627\u0644\u063a\u0631\u0628\u064a\u0629", + "lon_ne": " \u0627\u0644\u0632\u0627\u0648\u064a\u0629 \u0627\u0644\u0634\u0645\u0627\u0644\u064a\u0629 \u0627\u0644\u0634\u0631\u0642\u064a\u0629", + "lon_sw": " \u0627\u0644\u0632\u0627\u0648\u064a\u0629 \u0627\u0644\u062c\u0646\u0648\u0628\u064a\u0629 \u0627\u0644\u063a\u0631\u0628\u064a\u0629", + "mode": "\u062d\u0633\u0627\u0628", + "show_on_map": "\u0639\u0631\u0636 \u0639\u0644\u0649 \u0627\u0644\u062e\u0631\u064a\u0637\u0629" + }, + "description": "\u062a\u0643\u0648\u064a\u0646 \u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0637\u0642\u0633 \u0627\u0644\u0639\u0627\u0645 \u0644\u0645\u0646\u0637\u0642\u0629.", + "title": "\u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0637\u0642\u0633 \u0627\u0644\u0639\u0627\u0645 Netatmo" + }, + "public_weather_areas": { + "data": { + "new_area": "\u0627\u0633\u0645 \u0627\u0644\u0645\u0646\u0637\u0642\u0629", + "weather_areas": "\u0645\u0646\u0627\u0637\u0642 \u0627\u0644\u0637\u0642\u0633" + }, + "description": "\u062a\u0643\u0648\u064a\u0646 \u0623\u062c\u0647\u0632\u0629 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0637\u0642\u0633 \u0627\u0644\u0639\u0627\u0645\u0629.", + "title": "\u062c\u0647\u0627\u0632 \u0627\u0633\u062a\u0634\u0639\u0627\u0631 \u0627\u0644\u0637\u0642\u0633 \u0627\u0644\u0639\u0627\u0645 Netatmo" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/de.json b/homeassistant/components/netatmo/translations/de.json index 73106797381..becff2df430 100644 --- a/homeassistant/components/netatmo/translations/de.json +++ b/homeassistant/components/netatmo/translations/de.json @@ -7,11 +7,11 @@ "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "create_entry": { - "default": "Erfolgreich authentifiziert." + "default": "Erfolgreich authentifiziert" }, "step": { "pick_implementation": { - "title": "W\u00e4hle Authentifizierungs-Methode" + "title": "W\u00e4hle die Authentifizierungsmethode" } } }, @@ -49,7 +49,7 @@ "mode": "Berechnung", "show_on_map": "Auf Karte anzeigen" }, - "description": "Konfigurieren Sie einen \u00f6ffentlichen Wettersensor f\u00fcr einen Bereich.", + "description": "Konfiguriere einen \u00f6ffentlichen Wettersensor f\u00fcr einen Bereich.", "title": "\u00d6ffentlicher Netatmo Wettersensor" }, "public_weather_areas": { diff --git a/homeassistant/components/netatmo/translations/he.json b/homeassistant/components/netatmo/translations/he.json index 814a0093b2c..54571f698fe 100644 --- a/homeassistant/components/netatmo/translations/he.json +++ b/homeassistant/components/netatmo/translations/he.json @@ -26,5 +26,14 @@ "turned_off": "{entity_name} \u05db\u05d5\u05d1\u05d4", "turned_on": "{entity_name} \u05d4\u05d5\u05e4\u05e2\u05dc" } + }, + "options": { + "step": { + "public_weather": { + "data": { + "show_on_map": "\u05d4\u05e6\u05d2 \u05d1\u05de\u05e4\u05d4" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/hu.json b/homeassistant/components/netatmo/translations/hu.json index b4979396eeb..0e6536bb0ad 100644 --- a/homeassistant/components/netatmo/translations/hu.json +++ b/homeassistant/components/netatmo/translations/hu.json @@ -17,7 +17,9 @@ }, "device_automation": { "trigger_subtype": { - "away": "t\u00e1vol" + "away": "t\u00e1vol", + "hg": "fagyv\u00e9d\u0151", + "schedule": "\u00fctemez" }, "trigger_type": { "alarm_started": "{entity_name} riaszt\u00e1st \u00e9szlelt", diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 54db95e9aa0..4f39d5fe5f5 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,7 +1,10 @@ """The Netatmo integration.""" import logging +from aiohttp.web import Request + from homeassistant.const import ATTR_DEVICE_ID, ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -25,7 +28,9 @@ SUBEVENT_TYPE_MAP = { } -async def async_handle_webhook(hass, webhook_id, request): +async def async_handle_webhook( + hass: HomeAssistant, webhook_id: str, request: Request +) -> None: """Handle webhook callback.""" try: data = await request.json() @@ -47,12 +52,12 @@ async def async_handle_webhook(hass, webhook_id, request): async_evaluate_event(hass, data) -def async_evaluate_event(hass, event_data): +def async_evaluate_event(hass: HomeAssistant, event_data: dict) -> None: """Evaluate events from webhook.""" - event_type = event_data.get(ATTR_EVENT_TYPE) + event_type = event_data.get(ATTR_EVENT_TYPE, "None") if event_type == "person": - for person in event_data.get(ATTR_PERSONS): + for person in event_data.get(ATTR_PERSONS, {}): person_event_data = dict(event_data) person_event_data[ATTR_ID] = person.get(ATTR_ID) person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS].get( @@ -67,7 +72,7 @@ def async_evaluate_event(hass, event_data): async_send_event(hass, event_type, event_data) -def async_send_event(hass, event_type, data): +def async_send_event(hass: HomeAssistant, event_type: str, data: dict) -> None: """Send events.""" _LOGGER.debug("%s: %s", event_type, data) async_dispatcher_send( diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py index a254d06fc06..c39b1598c89 100644 --- a/homeassistant/components/netio/switch.py +++ b/homeassistant/components/netio/switch.py @@ -97,12 +97,12 @@ class NetioApiView(HomeAssistantView): for i in range(1, 5): out = "output%d" % i - states.append(data.get("%s_state" % out) == STATE_ON) - consumptions.append(float(data.get("%s_consumption" % out, 0))) + states.append(data.get(f"{out}_state") == STATE_ON) + consumptions.append(float(data.get(f"{out}_consumption", 0))) cumulated_consumptions.append( - float(data.get("%s_cumulatedConsumption" % out, 0)) / 1000 + float(data.get(f"{out}_cumulatedConsumption", 0)) / 1000 ) - start_dates.append(data.get("%s_consumptionStart" % out, "")) + start_dates.append(data.get(f"{out}_consumptionStart", "")) _LOGGER.debug( "%s: %s, %s, %s since %s", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 3f19103acaa..48903d145e7 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass +from . import util from .const import ( ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, @@ -31,6 +32,19 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: return network.adapters +@bind_hass +async def async_get_source_ip(hass: HomeAssistant, target_ip: str) -> str: + """Get the source ip for a target ip.""" + adapters = await async_get_adapters(hass) + all_ipv4s = [] + for adapter in adapters: + if adapter["enabled"] and (ipv4s := adapter["ipv4"]): + all_ipv4s.extend([ipv4["address"] for ipv4 in ipv4s]) + + source_ip = util.async_get_source_ip(target_ip) + return source_ip if source_ip in all_ipv4s else all_ipv4s[0] + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index ff69f026fef..8b695a52e13 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -16,7 +16,7 @@ ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] MDNS_TARGET_IP: Final = "224.0.0.251" - +PUBLIC_TARGET_IP: Final = "8.8.8.8" NETWORK_CONFIG_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index a453ec7f1df..105cbdb62b7 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==0.9.10"], + "requirements": ["nexia==0.9.11"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/homeassistant/components/nexia/translations/de.json b/homeassistant/components/nexia/translations/de.json index e2a9a9bc739..94094f08037 100644 --- a/homeassistant/components/nexia/translations/de.json +++ b/homeassistant/components/nexia/translations/de.json @@ -15,7 +15,7 @@ "password": "Passwort", "username": "Benutzername" }, - "title": "Stellen Sie eine Verbindung zu mynexia.com her" + "title": "Stelle eine Verbindung zu mynexia.com her" } } } diff --git a/homeassistant/components/nexia/translations/fr.json b/homeassistant/components/nexia/translations/fr.json index 8082f912bed..5cec9b66836 100644 --- a/homeassistant/components/nexia/translations/fr.json +++ b/homeassistant/components/nexia/translations/fr.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "brand": "Marque", "password": "Mot de passe", "username": "Nom d'utilisateur" }, diff --git a/homeassistant/components/nexia/translations/hu.json b/homeassistant/components/nexia/translations/hu.json index 7dedf459484..ac85fec6456 100644 --- a/homeassistant/components/nexia/translations/hu.json +++ b/homeassistant/components/nexia/translations/hu.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "brand": "M\u00e1rka", "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, diff --git a/homeassistant/components/nexia/translations/id.json b/homeassistant/components/nexia/translations/id.json index e6900bdffa1..0600315fc78 100644 --- a/homeassistant/components/nexia/translations/id.json +++ b/homeassistant/components/nexia/translations/id.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "brand": "Merek", "password": "Kata Sandi", "username": "Nama Pengguna" }, diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 9965265e00d..90a76c1c747 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1 +1,69 @@ -"""The nfandroidtv component.""" +"""The NFAndroidTV integration.""" +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications + +from homeassistant.components.notify import DOMAIN as NOTIFY +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import discovery + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [NOTIFY] + + +async def async_setup(hass: HomeAssistant, config): + """Set up the NFAndroidTV component.""" + hass.data.setdefault(DOMAIN, {}) + # Iterate all entries for notify to only get nfandroidtv + if NOTIFY in config: + for entry in config[NOTIFY]: + if entry[CONF_PLATFORM] == DOMAIN: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NFAndroidTV from a config entry.""" + host = entry.data[CONF_HOST] + name = entry.data[CONF_NAME] + + try: + await hass.async_add_executor_job(Notifications, host) + except ConnectError as ex: + _LOGGER.warning("Failed to connect: %s", ex) + raise ConfigEntryNotReady from ex + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + CONF_HOST: host, + CONF_NAME: name, + } + + hass.async_create_task( + discovery.async_load_platform( + hass, NOTIFY, DOMAIN, hass.data[DOMAIN][entry.entry_id], hass.data[DOMAIN] + ) + ) + + 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/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py new file mode 100644 index 00000000000..0f7cffcff4b --- /dev/null +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for NFAndroidTV integration.""" +from __future__ import annotations + +import logging + +from notifications_android_tv.notifications import ConnectError, Notifications +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NFAndroidTV.""" + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + name = user_input[CONF_NAME] + + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured() + error = await self._async_try_connect(host) + if error is None: + return self.async_create_entry( + title=name, + data={CONF_HOST: host, CONF_NAME: name}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == import_config[CONF_HOST]: + _LOGGER.warning( + "Already configured. This yaml configuration has already been imported. Please remove it" + ) + return self.async_abort(reason="already_configured") + if CONF_NAME not in import_config: + import_config[CONF_NAME] = f"{DEFAULT_NAME} {import_config[CONF_HOST]}" + + return await self.async_step_user(import_config) + + async def _async_try_connect(self, host): + """Try connecting to Android TV / Fire TV.""" + try: + await self.hass.async_add_executor_job(Notifications, host) + except ConnectError: + _LOGGER.error("Error connecting to device at %s", host) + return "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return "unknown" + return diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py new file mode 100644 index 00000000000..332c1754771 --- /dev/null +++ b/homeassistant/components/nfandroidtv/const.py @@ -0,0 +1,28 @@ +"""Constants for the NFAndroidTV integration.""" +DOMAIN: str = "nfandroidtv" +CONF_DURATION = "duration" +CONF_FONTSIZE = "fontsize" +CONF_POSITION = "position" +CONF_TRANSPARENCY = "transparency" +CONF_COLOR = "color" +CONF_INTERRUPT = "interrupt" + +DEFAULT_NAME = "Android TV / Fire TV" +DEFAULT_TIMEOUT = 5 + +ATTR_DURATION = "duration" +ATTR_FONTSIZE = "fontsize" +ATTR_POSITION = "position" +ATTR_TRANSPARENCY = "transparency" +ATTR_COLOR = "color" +ATTR_BKGCOLOR = "bkgcolor" +ATTR_INTERRUPT = "interrupt" +ATTR_FILE = "file" +# Attributes contained in file +ATTR_FILE_URL = "url" +ATTR_FILE_PATH = "path" +ATTR_FILE_USERNAME = "username" +ATTR_FILE_PASSWORD = "password" +ATTR_FILE_AUTH = "auth" +# Any other value or absence of 'auth' lead to basic authentication being used +ATTR_FILE_AUTH_DIGEST = "digest" diff --git a/homeassistant/components/nfandroidtv/manifest.json b/homeassistant/components/nfandroidtv/manifest.json index 6f29d4d410e..5516f144fd4 100644 --- a/homeassistant/components/nfandroidtv/manifest.json +++ b/homeassistant/components/nfandroidtv/manifest.json @@ -1,7 +1,9 @@ { "domain": "nfandroidtv", - "name": "Notifications for Android TV / FireTV", + "name": "Notifications for Android TV / Fire TV", "documentation": "https://www.home-assistant.io/integrations/nfandroidtv", - "codeowners": [], + "requirements": ["notifications-android-tv==0.1.2"], + "codeowners": ["@tkdrob"], + "config_flow": true, "iot_class": "local_push" } diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index ad2f3fb3706..8cc1b0031f7 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -1,8 +1,7 @@ """Notifications for Android TV notification service.""" -import base64 -import io import logging +from notifications_android_tv import Notifications import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -14,115 +13,69 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT, HTTP_OK, PERCENTAGE +from homeassistant.const import ATTR_ICON, CONF_HOST, CONF_TIMEOUT +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from .const import ( + ATTR_COLOR, + ATTR_DURATION, + ATTR_FILE, + ATTR_FILE_AUTH, + ATTR_FILE_AUTH_DIGEST, + ATTR_FILE_PASSWORD, + ATTR_FILE_PATH, + ATTR_FILE_URL, + ATTR_FILE_USERNAME, + ATTR_FONTSIZE, + ATTR_INTERRUPT, + ATTR_POSITION, + ATTR_TRANSPARENCY, + CONF_COLOR, + CONF_DURATION, + CONF_FONTSIZE, + CONF_INTERRUPT, + CONF_POSITION, + CONF_TRANSPARENCY, + DEFAULT_TIMEOUT, +) + _LOGGER = logging.getLogger(__name__) -CONF_DURATION = "duration" -CONF_FONTSIZE = "fontsize" -CONF_POSITION = "position" -CONF_TRANSPARENCY = "transparency" -CONF_COLOR = "color" -CONF_INTERRUPT = "interrupt" - -DEFAULT_DURATION = 5 -DEFAULT_FONTSIZE = "medium" -DEFAULT_POSITION = "bottom-right" -DEFAULT_TRANSPARENCY = "default" -DEFAULT_COLOR = "grey" -DEFAULT_INTERRUPT = False -DEFAULT_TIMEOUT = 5 -DEFAULT_ICON = ( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo" - "cMXEAAAAASUVORK5CYII=" -) - -ATTR_DURATION = "duration" -ATTR_FONTSIZE = "fontsize" -ATTR_POSITION = "position" -ATTR_TRANSPARENCY = "transparency" -ATTR_COLOR = "color" -ATTR_BKGCOLOR = "bkgcolor" -ATTR_INTERRUPT = "interrupt" -ATTR_IMAGE = "filename2" -ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" -# Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" - -FONTSIZES = {"small": 1, "medium": 0, "large": 2, "max": 3} - -POSITIONS = { - "bottom-right": 0, - "bottom-left": 1, - "top-right": 2, - "top-left": 3, - "center": 4, -} - -TRANSPARENCIES = { - "default": 0, - f"0{PERCENTAGE}": 1, - f"25{PERCENTAGE}": 2, - f"50{PERCENTAGE}": 3, - f"75{PERCENTAGE}": 4, - f"100{PERCENTAGE}": 5, -} - -COLORS = { - "grey": "#607d8b", - "black": "#000000", - "indigo": "#303F9F", - "green": "#4CAF50", - "red": "#F44336", - "cyan": "#00BCD4", - "teal": "#009688", - "amber": "#FFC107", - "pink": "#E91E63", -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int), - vol.Optional(CONF_FONTSIZE, default=DEFAULT_FONTSIZE): vol.In(FONTSIZES.keys()), - vol.Optional(CONF_POSITION, default=DEFAULT_POSITION): vol.In(POSITIONS.keys()), - vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY): vol.In( - TRANSPARENCIES.keys() +# Deprecated in Home Assistant 2021.8 +PLATFORM_SCHEMA = cv.deprecated( + vol.All( + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DURATION): vol.Coerce(int), + vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()), + vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()), + vol.Optional(CONF_TRANSPARENCY): vol.In( + Notifications.TRANSPARENCIES.keys() + ), + vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()), + vol.Optional(CONF_TIMEOUT): vol.Coerce(int), + vol.Optional(CONF_INTERRUPT): cv.boolean, + } ), - vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): vol.In(COLORS.keys()), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_INTERRUPT, default=DEFAULT_INTERRUPT): cv.boolean, - } + ) ) -def get_service(hass, config, discovery_info=None): - """Get the Notifications for Android TV notification service.""" - remoteip = config.get(CONF_HOST) - duration = config.get(CONF_DURATION) - fontsize = config.get(CONF_FONTSIZE) - position = config.get(CONF_POSITION) - transparency = config.get(CONF_TRANSPARENCY) - color = config.get(CONF_COLOR) - interrupt = config.get(CONF_INTERRUPT) - timeout = config.get(CONF_TIMEOUT) - +async def async_get_service(hass: HomeAssistant, config, discovery_info=None): + """Get the NFAndroidTV notification service.""" + if discovery_info is not None: + notify = await hass.async_add_executor_job( + Notifications, discovery_info[CONF_HOST] + ) + return NFAndroidTVNotificationService( + notify, + hass.config.is_allowed_path, + ) + notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST)) return NFAndroidTVNotificationService( - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify, hass.config.is_allowed_path, ) @@ -132,116 +85,98 @@ class NFAndroidTVNotificationService(BaseNotificationService): def __init__( self, - remoteip, - duration, - fontsize, - position, - transparency, - color, - interrupt, - timeout, + notify: Notifications, is_allowed_path, ): """Initialize the service.""" - self._target = f"http://{remoteip}:7676" - self._default_duration = duration - self._default_fontsize = fontsize - self._default_position = position - self._default_transparency = transparency - self._default_color = color - self._default_interrupt = interrupt - self._timeout = timeout - self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON)) + self.notify = notify self.is_allowed_path = is_allowed_path def send_message(self, message="", **kwargs): """Send a message to a Android TV device.""" - _LOGGER.debug("Sending notification to: %s", self._target) - - payload = { - "filename": ( - "icon.png", - self._icon_file, - "application/octet-stream", - {"Expires": "0"}, - ), - "type": "0", - "title": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - "msg": message, - "duration": "%i" % self._default_duration, - "fontsize": "%i" % FONTSIZES.get(self._default_fontsize), - "position": "%i" % POSITIONS.get(self._default_position), - "bkgcolor": "%s" % COLORS.get(self._default_color), - "transparency": "%i" % TRANSPARENCIES.get(self._default_transparency), - "offset": "0", - "app": ATTR_TITLE_DEFAULT, - "force": "true", - "interrupt": "%i" % self._default_interrupt, - } - data = kwargs.get(ATTR_DATA) + title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) + duration = None + fontsize = None + position = None + transparency = None + bkgcolor = None + interrupt = None + icon = None + image_file = None if data: if ATTR_DURATION in data: - duration = data.get(ATTR_DURATION) try: - payload[ATTR_DURATION] = "%i" % int(duration) + duration = int(data.get(ATTR_DURATION)) except ValueError: - _LOGGER.warning("Invalid duration-value: %s", str(duration)) + _LOGGER.warning( + "Invalid duration-value: %s", str(data.get(ATTR_DURATION)) + ) if ATTR_FONTSIZE in data: - fontsize = data.get(ATTR_FONTSIZE) - if fontsize in FONTSIZES: - payload[ATTR_FONTSIZE] = "%i" % FONTSIZES.get(fontsize) + if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: + fontsize = data.get(ATTR_FONTSIZE) else: - _LOGGER.warning("Invalid fontsize-value: %s", str(fontsize)) + _LOGGER.warning( + "Invalid fontsize-value: %s", str(data.get(ATTR_FONTSIZE)) + ) if ATTR_POSITION in data: - position = data.get(ATTR_POSITION) - if position in POSITIONS: - payload[ATTR_POSITION] = "%i" % POSITIONS.get(position) + if data.get(ATTR_POSITION) in Notifications.POSITIONS: + position = data.get(ATTR_POSITION) else: - _LOGGER.warning("Invalid position-value: %s", str(position)) + _LOGGER.warning( + "Invalid position-value: %s", str(data.get(ATTR_POSITION)) + ) if ATTR_TRANSPARENCY in data: - transparency = data.get(ATTR_TRANSPARENCY) - if transparency in TRANSPARENCIES: - payload[ATTR_TRANSPARENCY] = "%i" % TRANSPARENCIES.get(transparency) + if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES: + transparency = data.get(ATTR_TRANSPARENCY) else: - _LOGGER.warning("Invalid transparency-value: %s", str(transparency)) + _LOGGER.warning( + "Invalid transparency-value: %s", + str(data.get(ATTR_TRANSPARENCY)), + ) if ATTR_COLOR in data: - color = data.get(ATTR_COLOR) - if color in COLORS: - payload[ATTR_BKGCOLOR] = "%s" % COLORS.get(color) + if data.get(ATTR_COLOR) in Notifications.BKG_COLORS: + bkgcolor = data.get(ATTR_COLOR) else: - _LOGGER.warning("Invalid color-value: %s", str(color)) + _LOGGER.warning( + "Invalid color-value: %s", str(data.get(ATTR_COLOR)) + ) if ATTR_INTERRUPT in data: - interrupt = data.get(ATTR_INTERRUPT) try: - payload[ATTR_INTERRUPT] = "%i" % cv.boolean(interrupt) + interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) except vol.Invalid: - _LOGGER.warning("Invalid interrupt-value: %s", str(interrupt)) + _LOGGER.warning( + "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) + ) filedata = data.get(ATTR_FILE) if data else None if filedata is not None: - # Load from file or URL - file_as_bytes = self.load_file( + if ATTR_ICON in filedata: + icon = self.load_file( + url=filedata.get(ATTR_ICON), + local_path=filedata.get(ATTR_FILE_PATH), + username=filedata.get(ATTR_FILE_USERNAME), + password=filedata.get(ATTR_FILE_PASSWORD), + auth=filedata.get(ATTR_FILE_AUTH), + ) + image_file = self.load_file( url=filedata.get(ATTR_FILE_URL), local_path=filedata.get(ATTR_FILE_PATH), username=filedata.get(ATTR_FILE_USERNAME), password=filedata.get(ATTR_FILE_PASSWORD), auth=filedata.get(ATTR_FILE_AUTH), ) - if file_as_bytes: - payload[ATTR_IMAGE] = ( - "image", - file_as_bytes, - "application/octet-stream", - {"Expires": "0"}, - ) - - try: - _LOGGER.debug("Payload: %s", str(payload)) - response = requests.post(self._target, files=payload, timeout=self._timeout) - if response.status_code != HTTP_OK: - _LOGGER.error("Error sending message: %s", str(response)) - except requests.exceptions.ConnectionError as err: - _LOGGER.error("Error communicating with %s: %s", self._target, str(err)) + self.notify.send( + message, + title=title, + duration=duration, + fontsize=fontsize, + position=position, + bkgcolor=bkgcolor, + transparency=transparency, + interrupt=interrupt, + icon=icon, + image_file=image_file, + ) def load_file( self, url=None, local_path=None, username=None, password=None, auth=None diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json new file mode 100644 index 00000000000..5940f86a406 --- /dev/null +++ b/homeassistant/components/nfandroidtv/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Notifications for Android TV / Fire TV", + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "name": "[%key:common::config_flow::data::name%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/nfandroidtv/translations/ca.json b/homeassistant/components/nfandroidtv/translations/ca.json new file mode 100644 index 00000000000..861ad41a39b --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + }, + "description": "Aquesta integraci\u00f3 necessita l'aplicaci\u00f3 Notificacions per a Android TV. \n\nPer Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPer Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nHauries de configurar o b\u00e9 una reserva DHCP al router (consulta el manual del teu rounter) o b\u00e9 adre\u00e7a IP est\u00e0tica al dispositiu. Si no o fas, el disositiu acabar\u00e0 deixant d'estar disponible.", + "title": "Notificacions per a Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/cs.json b/homeassistant/components/nfandroidtv/translations/cs.json new file mode 100644 index 00000000000..b268d8945a0 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + }, + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/de.json b/homeassistant/components/nfandroidtv/translations/de.json new file mode 100644 index 00000000000..b3adce9ac07 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "Diese Integration erfordert die App \"Benachrichtigungen f\u00fcr Android TV\".\n\nF\u00fcr Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nF\u00fcr Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nDu solltest entweder eine DHCP-Reservierung auf deinem Router (siehe Benutzerhandbuch deines Routers) oder eine statische IP-Adresse auf dem Ger\u00e4t einrichten. Andernfalls wird das Ger\u00e4t irgendwann nicht mehr verf\u00fcgbar sein.", + "title": "Benachrichtigungen f\u00fcr Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/en.json b/homeassistant/components/nfandroidtv/translations/en.json new file mode 100644 index 00000000000..f117428df35 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + }, + "description": "This integration requires the Notifications for Android TV app.\n\nFor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nYou should set up either DHCP reservation on your router (refer to your router's user manual) or a static IP address on the device. If not, the device will eventually become unavailable.", + "title": "Notifications for Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/et.json b/homeassistant/components/nfandroidtv/translations/et.json new file mode 100644 index 00000000000..f2405ab1421 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + }, + "description": "See sidumine n\u00f5uab Android TV rakenduse Notifications for Android TV kasutamist.\n\nAndroid TV jaoks: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV jaoks: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nPead seadma ruuterile kas DHCP-reservatsiooni (vt ruuteri kasutusjuhendit) v\u00f5i seadme staatilise IP-aadressi. Vastasel juhul muutub seade l\u00f5puks k\u00e4ttesaamatuks.", + "title": "Android TV / Fire TV teavitused" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/fr.json b/homeassistant/components/nfandroidtv/translations/fr.json new file mode 100644 index 00000000000..6d00852889b --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + }, + "description": "Cette int\u00e9gration n\u00e9cessite l'application Notifications pour Android TV. \n\nPour Android TV\u00a0: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPour Fire TV\u00a0: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nVous devez configurer soit une r\u00e9servation DHCP sur votre routeur (reportez-vous au manuel d'utilisation de votre routeur) soit une adresse IP statique sur l'appareil. Sinon, l'appareil finira par devenir indisponible.", + "title": "Notifications pour Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/he.json b/homeassistant/components/nfandroidtv/translations/he.json new file mode 100644 index 00000000000..70ced66b0a5 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/he.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/it.json b/homeassistant/components/nfandroidtv/translations/it.json new file mode 100644 index 00000000000..3b8d089b5a5 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + }, + "description": "Questa integrazione richiede l'app Notifiche per Android TV. \n\nPer Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nPer Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n\u00c8 necessario impostare la prenotazione DHCP sul router (fare riferimento al manuale utente del router) o un indirizzo IP statico sul dispositivo. In caso contrario, il dispositivo alla fine non sar\u00e0 pi\u00f9 disponibile.", + "title": "Notifiche per Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/nl.json b/homeassistant/components/nfandroidtv/translations/nl.json new file mode 100644 index 00000000000..acd936abe70 --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam" + }, + "description": "Voor deze integratie is de app Notifications for Android TV vereist.\n\nVoor Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nVoor Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\nU moet een DHCP-reservering op uw router instellen (raadpleeg de gebruikershandleiding van uw router) of een statisch IP-adres op het apparaat instellen. Zo niet, dan zal het apparaat uiteindelijk onbeschikbaar worden.", + "title": "Meldingen voor Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/pl.json b/homeassistant/components/nfandroidtv/translations/pl.json new file mode 100644 index 00000000000..4dd742b7c1f --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa" + }, + "description": "Ta integracja wymaga aplikacji Powiadomienia dla Androida TV. \n\nAndroid TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\nNale\u017cy skonfigurowa\u0107 rezerwacj\u0119 DHCP na routerze (patrz instrukcja obs\u0142ugi routera) lub ustawi\u0107 statyczny adres IP na urz\u0105dzeniu. Je\u015bli tego nie zrobisz, urz\u0105dzenie ostatecznie stanie si\u0119 niedost\u0119pne.", + "title": "Powiadomienia dla Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/ru.json b/homeassistant/components/nfandroidtv/translations/ru.json new file mode 100644 index 00000000000..ce0d4651dfc --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + }, + "description": "\u0414\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \"Notifications for Android TV\". \n\n\u0414\u043b\u044f Android TV: https://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\n\u0414\u043b\u044f Fire TV: https://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK \n\n\u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 DHCP \u043d\u0430 \u0432\u0430\u0448\u0435\u043c \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0435 (\u0441\u043c\u043e\u0442\u0440\u0438\u0442\u0435 \u0440\u0443\u043a\u043e\u0432\u043e\u0434\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u043c\u0430\u0440\u0448\u0440\u0443\u0442\u0438\u0437\u0430\u0442\u043e\u0440\u0443) \u0438\u043b\u0438 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0435. \u0412 \u043f\u0440\u043e\u0442\u0438\u0432\u043d\u043e\u043c \u0441\u043b\u0443\u0447\u0430\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043c\u043e\u0436\u0435\u0442 \u0441\u0442\u0430\u0442\u044c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c.", + "title": "Notifications for Android TV / Fire TV" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nfandroidtv/translations/zh-Hant.json b/homeassistant/components/nfandroidtv/translations/zh-Hant.json new file mode 100644 index 00000000000..b16d55a44bd --- /dev/null +++ b/homeassistant/components/nfandroidtv/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + }, + "description": "\u6b64\u6574\u5408\u9700\u8981\u5b89\u88dd Notifications for Android TV App\u3002\n\nAndroid TV \u7248\u672c\uff1ahttps://play.google.com/store/apps/details?id=de.cyberdream.androidtv.notifications.google\nFire TV \u7248\u672c\uff1ahttps://www.amazon.com/Christian-Fees-Notifications-for-Fire/dp/B00OESCXEK\n\n\u8acb\u65bc\u8def\u7531\u5668\uff08\u8acb\u53c3\u8003\u8def\u7531\u5668\u624b\u518a\uff09\u4e2d\u8a2d\u5b9a\u4fdd\u7559\u88dd\u7f6e DHCP IP \u6216\u975c\u614b IP\u3002\u5047\u5982\u672a\u9032\u884c\u6b64\u8a2d\u5b9a\uff0c\u88dd\u7f6e\u53ef\u80fd\u6703\u8b8a\u6210\u4e0d\u53ef\u7528\u3002", + "title": "Android TV / Fire TV \u901a\u77e5" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nightscout/translations/de.json b/homeassistant/components/nightscout/translations/de.json index 91461416c60..21bea5ee877 100644 --- a/homeassistant/components/nightscout/translations/de.json +++ b/homeassistant/components/nightscout/translations/de.json @@ -15,8 +15,8 @@ "api_key": "API-Schl\u00fcssel", "url": "URL" }, - "description": "- URL: die Adresse Ihrer Nightscout-Instanz. Z.B.: https://myhomeassistant.duckdns.org:5423\n- API-Schl\u00fcssel (Optional): Nur verwenden, wenn Ihre Instanz gesch\u00fctzt ist (auth_default_roles != readable).", - "title": "Geben Sie Ihre Nightscout-Serverinformationen ein." + "description": "- URL: die Adresse deiner Nightscout-Instanz. Z.B.: https://myhomeassistant.duckdns.org:5423\n- API-Schl\u00fcssel (Optional): Nur verwenden, wenn deine Instanz gesch\u00fctzt ist (auth_default_roles != readable).", + "title": "Gib deine Nightscout-Serverinformationen ein." } } } diff --git a/homeassistant/components/nightscout/translations/hu.json b/homeassistant/components/nightscout/translations/hu.json index 459a879e82c..b3e5a36e172 100644 --- a/homeassistant/components/nightscout/translations/hu.json +++ b/homeassistant/components/nightscout/translations/hu.json @@ -14,7 +14,9 @@ "data": { "api_key": "API kulcs", "url": "URL" - } + }, + "description": "- URL: a nightcout p\u00e9ld\u00e1ny c\u00edme. Vagyis: https://myhomeassistant.duckdns.org:5423\n - API kulcs (opcion\u00e1lis): Csak akkor haszn\u00e1lja, ha a p\u00e9ld\u00e1nya v\u00e9dett (auth_default_roles! = Olvashat\u00f3).", + "title": "Adja meg a Nightscout szerver adatait." } } } diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py index fb5b4c75798..77eb7945ab1 100644 --- a/homeassistant/components/nilu/air_quality.py +++ b/homeassistant/components/nilu/air_quality.py @@ -252,7 +252,7 @@ class NiluSensor(AirQualityEntity): sensors = self._api.data.sensors.values() if sensors: - max_index = max([s.pollution_index for s in sensors]) + max_index = max(s.pollution_index for s in sensors) self._max_aqi = max_index self._attrs[ATTR_POLLUTION_INDEX] = POLLUTION_INDEX[self._max_aqi] diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 24adf223719..a0a39542a20 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -45,7 +45,7 @@ DEFAULT_CLIMATE_INTERVAL = timedelta(minutes=5) RESTRICTED_BATTERY = 2 RESTRICTED_INTERVAL = timedelta(hours=12) -MAX_RESPONSE_ATTEMPTS = 10 +MAX_RESPONSE_ATTEMPTS = 3 PYCARWINGS2_SLEEP = 30 @@ -194,6 +194,14 @@ def setup(hass, config): return True +def _extract_start_date(battery_info): + """Extract the server date from the battery response.""" + try: + return battery_info.answer["BatteryStatusRecords"]["OperationDateAndTime"] + except KeyError: + return None + + class LeafDataStore: """Nissan Leaf Data Store.""" @@ -324,19 +332,24 @@ class LeafDataStore: self.request_in_progress = False async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) - @staticmethod - def _extract_start_date(battery_info): - """Extract the server date from the battery response.""" - try: - return battery_info.answer["BatteryStatusRecords"]["OperationDateAndTime"] - except KeyError: - return None - async def async_get_battery(self): """Request battery update from Nissan servers.""" try: # Request battery update from the car _LOGGER.debug("Requesting battery update, %s", self.leaf.vin) + start_date = None + try: + start_server_info = await self.hass.async_add_executor_job( + self.leaf.get_latest_battery_status + ) + except TypeError: # pycarwings2 can fail if Nissan returns nothing + _LOGGER.debug("Battery status check returned nothing") + else: + if not start_server_info: + _LOGGER.debug("Battery status check failed") + else: + start_date = _extract_start_date(start_server_info) + await asyncio.sleep(1) # Critical sleep request = await self.hass.async_add_executor_job(self.leaf.request_update) if not request: _LOGGER.error("Battery update request failed") @@ -364,7 +377,19 @@ class LeafDataStore: server_info = await self.hass.async_add_executor_job( self.leaf.get_latest_battery_status ) - return server_info + if not start_date or ( + server_info and start_date != _extract_start_date(server_info) + ): + return server_info + # get_status_from_update returned {"resultFlag": "1"} + # but the data didn't change, make a fresh request. + await asyncio.sleep(1) # Critical sleep + request = await self.hass.async_add_executor_job( + self.leaf.request_update + ) + if not request: + _LOGGER.error("Battery update request failed") + return None _LOGGER.debug( "%s attempts exceeded return latest data from server", @@ -379,7 +404,7 @@ class LeafDataStore: except CarwingsError: _LOGGER.error("An error occurred getting battery status") return None - except KeyError: + except (KeyError, TypeError): _LOGGER.error("An error occurred parsing response from server") return None diff --git a/homeassistant/components/nmap_tracker/translations/ar.json b/homeassistant/components/nmap_tracker/translations/ar.json new file mode 100644 index 00000000000..9f223966416 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/ar.json @@ -0,0 +1,26 @@ +{ + "config": { + "error": { + "invalid_hosts": "\u0627\u0644\u0645\u0636\u064a\u0641\u0648\u0646 \u063a\u064a\u0631 \u0635\u0627\u0644\u062d\u064a\u0646" + }, + "step": { + "user": { + "data": { + "home_interval": "\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 \u0644\u0639\u062f\u062f \u0627\u0644\u062f\u0642\u0627\u0626\u0642 \u0628\u064a\u0646 \u0645\u0633\u062d \u0627\u0644\u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u0646\u0634\u0637\u0629 (\u0644\u0644\u062d\u0641\u0627\u0638 \u0639\u0644\u0649 \u0627\u0644\u0628\u0637\u0627\u0631\u064a\u0629)", + "hosts": "\u0639\u0646\u0627\u0648\u064a\u0646 \u0627\u0644\u0634\u0628\u0643\u0629 (\u0645\u0641\u0635\u0648\u0644\u0629 \u0628\u0641\u0648\u0627\u0635\u0644) \u0644\u0644\u0645\u0633\u062d" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "interval_seconds": "\u0641\u062a\u0631\u0629 \u062a\u0643\u0631\u0627\u0631 \u0627\u0644\u0628\u062d\u062b", + "track_new_devices": "\u062a\u062a\u0628\u0639 \u0627\u0644\u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u062c\u062f\u064a\u062f\u0629" + } + } + } + }, + "title": "Nmap \u0627\u0644\u0645\u0642\u062a\u0641\u064a" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/ca.json b/homeassistant/components/nmap_tracker/translations/ca.json new file mode 100644 index 00000000000..857772081d8 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/ca.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada" + }, + "error": { + "invalid_hosts": "Amfitrions no v\u00e0lids" + }, + "step": { + "user": { + "data": { + "exclude": "Adreces de xarxa a excloure de l'escaneig (separades per comes)", + "home_interval": "Nombre m\u00ednim de minuts entre escanejos de dispositius actius (conserva la bateria)", + "hosts": "Adreces de xarxa a escanejar (separades per comes)", + "scan_options": "Opcions de configuraci\u00f3 d'escaneig d'Nmap en brut" + }, + "description": "Configura els amfitrions a explorar per Nmap. L'adre\u00e7a de xarxa i les exclusions poden ser adreces IP (192.168.1.1), xarxes IP (192.168.0.0/24) o intervals IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Amfitrions no v\u00e0lids" + }, + "step": { + "init": { + "data": { + "exclude": "Adreces de xarxa a excloure de l'escaneig (separades per comes)", + "home_interval": "Nombre m\u00ednim de minuts entre escanejos de dispositius actius (conserva la bateria)", + "hosts": "Adreces de xarxa a escanejar (separades per comes)", + "interval_seconds": "Interval d'escaneig", + "scan_options": "Opcions de configuraci\u00f3 d'escaneig d'Nmap en brut", + "track_new_devices": "Segueix dispositius nous" + }, + "description": "Configura els amfitrions a explorar per Nmap. L'adre\u00e7a de xarxa i les exclusions poden ser adreces IP (192.168.1.1), xarxes IP (192.168.0.0/24) o intervals IP (192.168.1.0-32)." + } + } + }, + "title": "Seguidor Nmap" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/cs.json b/homeassistant/components/nmap_tracker/translations/cs.json new file mode 100644 index 00000000000..1a0d0ae0b53 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/cs.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Um\u00edst\u011bn\u00ed je ji\u017e nastaveno" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/de.json b/homeassistant/components/nmap_tracker/translations/de.json index 67c7e8dbd8a..729a964059f 100644 --- a/homeassistant/components/nmap_tracker/translations/de.json +++ b/homeassistant/components/nmap_tracker/translations/de.json @@ -2,15 +2,35 @@ "config": { "abort": { "already_configured": "Standort ist bereits konfiguriert" + }, + "error": { + "invalid_hosts": "Ung\u00fcltige Hosts" + }, + "step": { + "user": { + "data": { + "exclude": "Netzwerkadressen (kommagetrennt), die von der \u00dcberpr\u00fcfung ausgeschlossen werden sollen", + "home_interval": "Mindestanzahl von Minuten zwischen den Scans aktiver Ger\u00e4te (Batterie schonen)", + "hosts": "Netzwerkadressen (kommagetrennt) zum Scannen", + "scan_options": "Raw konfigurierbare Scan-Optionen f\u00fcr Nmap" + }, + "description": "Konfiguriere die Hosts, die von Nmap gescannt werden sollen. Netzwerkadresse und Ausschl\u00fcsse k\u00f6nnen IP-Adressen (192.168.1.1), IP-Netzwerke (192.168.0.0/24) oder IP-Bereiche (192.168.1.0-32) sein." + } } }, "options": { + "error": { + "invalid_hosts": "Ung\u00fcltige Hosts" + }, "step": { "init": { "data": { + "exclude": "Netzwerkadressen (kommagetrennt), die von der \u00dcberpr\u00fcfung ausgeschlossen werden sollen", "home_interval": "Mindestanzahl von Minuten zwischen den Scans aktiver Ger\u00e4te (Batterie schonen)", - "hosts": "Zu scannende Netzwerkadressen (kommagetrennt)", - "scan_options": "Raw konfigurierbare Scan-Optionen f\u00fcr Nmap" + "hosts": "Netzwerkadressen (kommagetrennt) zum Scannen", + "interval_seconds": "Scanintervall", + "scan_options": "Raw konfigurierbare Scan-Optionen f\u00fcr Nmap", + "track_new_devices": "Neue Ger\u00e4te verfolgen" }, "description": "Konfiguriere die Hosts, die von Nmap gescannt werden sollen. Netzwerkadresse und Ausschl\u00fcsse k\u00f6nnen IP-Adressen (192.168.1.1), IP-Netzwerke (192.168.0.0/24) oder IP-Bereiche (192.168.1.0-32) sein." } diff --git a/homeassistant/components/nmap_tracker/translations/es.json b/homeassistant/components/nmap_tracker/translations/es.json new file mode 100644 index 00000000000..d5c3d71321f --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/es.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada" + }, + "error": { + "invalid_hosts": "Hosts no v\u00e1lidos" + }, + "step": { + "user": { + "data": { + "exclude": "Direcciones de red (separadas por comas) para excluir del escaneo", + "home_interval": "N\u00famero m\u00ednimo de minutos entre los escaneos de los dispositivos activos (preservar la bater\u00eda)", + "hosts": "Direcciones de red (separadas por comas) para escanear", + "scan_options": "Opciones de escaneo configurables sin procesar para Nmap" + }, + "description": "Configure los hosts que ser\u00e1n escaneados por Nmap. Las direcciones de red y los excluidos pueden ser direcciones IP (192.168.1.1), redes IP (192.168.0.0/24) o rangos IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Hosts no v\u00e1lidos" + }, + "step": { + "init": { + "data": { + "exclude": "Direcciones de red (separadas por comas) para excluir del escaneo", + "home_interval": "N\u00famero m\u00ednimo de minutos entre los escaneos de los dispositivos activos (preservar la bater\u00eda)", + "hosts": "Direcciones de red (separadas por comas) para escanear", + "interval_seconds": "Intervalo de exploraci\u00f3n", + "scan_options": "Opciones de escaneo configurables sin procesar para Nmap", + "track_new_devices": "Seguimiento de nuevos dispositivos" + }, + "description": "Configure los hosts que ser\u00e1n escaneados por Nmap. Las direcciones de red y los excluidos pueden ser direcciones IP (192.168.1.1), redes IP (192.168.0.0/24) o rangos IP (192.168.1.0-32)." + } + } + }, + "title": "Rastreador de Nmap" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/et.json b/homeassistant/components/nmap_tracker/translations/et.json index 8b98dbc87cc..09b46a15889 100644 --- a/homeassistant/components/nmap_tracker/translations/et.json +++ b/homeassistant/components/nmap_tracker/translations/et.json @@ -28,7 +28,9 @@ "exclude": "V\u00e4listatud IP aadresside vahemik (komadega eraldatud list)", "home_interval": "Minimaalne sk\u00e4nnimiste intervall minutites (eeldus on aku s\u00e4\u00e4stmine)", "hosts": "V\u00f5rguaadresside vahemik (komaga eraldatud)", - "scan_options": "Vaikimisi Nmap sk\u00e4nnimise valikud" + "interval_seconds": "P\u00e4ringute intervall", + "scan_options": "Vaikimisi Nmap sk\u00e4nnimise valikud", + "track_new_devices": "Uute seadmete j\u00e4lgimine" }, "description": "Vali Nmap poolt sk\u00e4nnitavad hostid. Valikuks on IP aadressid (192.168.1.1), v\u00f5rgud (192.168.0.0/24) v\u00f5i IP vahemikud (192.168.1.0-32)." } diff --git a/homeassistant/components/nmap_tracker/translations/fr.json b/homeassistant/components/nmap_tracker/translations/fr.json new file mode 100644 index 00000000000..69d7d58f2e6 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/fr.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_hosts": "H\u00f4tes invalides" + }, + "step": { + "user": { + "data": { + "exclude": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 exclure de l'analyse", + "home_interval": "Nombre minimum de minutes entre les analyses des appareils actifs (pr\u00e9server la batterie)", + "hosts": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 analyser", + "scan_options": "Options d'analyse brutes configurables pour Nmap" + }, + "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1), des r\u00e9seaux IP (192.168.0.0/24) ou des plages IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "H\u00f4tes invalides" + }, + "step": { + "init": { + "data": { + "exclude": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 exclure de l'analyse", + "home_interval": "Nombre minimum de minutes entre les analyses des appareils actifs (pr\u00e9server la batterie)", + "hosts": "Adresses r\u00e9seau (s\u00e9par\u00e9es par des virgules) \u00e0 analyser", + "interval_seconds": "Intervalle d\u2019analyse", + "scan_options": "Options d'analyse brutes configurables pour Nmap", + "track_new_devices": "Suivre les nouveaux appareils" + }, + "description": "Configurer les h\u00f4tes \u00e0 analyser par Nmap. L'adresse r\u00e9seau et les exclusions peuvent \u00eatre des adresses IP (192.168.1.1),R\u00e9seaux IP (192.168.0.0/24) ou plages IP (192.168.1.0-32)." + } + } + }, + "title": "Traqueur Nmap" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/he.json b/homeassistant/components/nmap_tracker/translations/he.json new file mode 100644 index 00000000000..d57ca363944 --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/he.json @@ -0,0 +1,39 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05de\u05d9\u05e7\u05d5\u05dd \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "step": { + "user": { + "data": { + "exclude": "\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05e8\u05e9\u05ea (\u05de\u05d5\u05e4\u05e8\u05d3\u05d5\u05ea \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e4\u05e1\u05d9\u05e7) \u05e9\u05dc\u05d0 \u05d9\u05d9\u05db\u05dc\u05dc\u05d5 \u05d1\u05e1\u05e8\u05d9\u05e7\u05d4", + "home_interval": "\u05de\u05e1\u05e4\u05e8 \u05de\u05d9\u05e0\u05d9\u05de\u05dc\u05d9 \u05e9\u05dc \u05d3\u05e7\u05d5\u05ea \u05d1\u05d9\u05df \u05e1\u05e8\u05d9\u05e7\u05d5\u05ea \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05e4\u05e2\u05d9\u05dc\u05d9\u05dd (\u05e9\u05d9\u05de\u05d5\u05e8 \u05e1\u05d5\u05dc\u05dc\u05d4)", + "hosts": "\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05e8\u05e9\u05ea (\u05de\u05d5\u05e4\u05e8\u05d3\u05d5\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7) \u05dc\u05e1\u05e8\u05d9\u05e7\u05d4", + "scan_options": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05e1\u05e8\u05d9\u05e7\u05d4 \u05d2\u05d5\u05dc\u05de\u05d9\u05d5\u05ea \u05d4\u05e0\u05d9\u05ea\u05e0\u05d5\u05ea \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4 \u05e2\u05d1\u05d5\u05e8 Nmap" + }, + "description": "\u05e7\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05e9\u05d9\u05e1\u05e8\u05e7\u05d5 \u05e2\u05dc \u05d9\u05d3\u05d9 Nmap. \u05db\u05ea\u05d5\u05d1\u05ea \u05e8\u05e9\u05ea \u05d5\u05d0\u05d9 \u05d4\u05db\u05dc\u05dc\u05d4 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05d9\u05d5\u05ea \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP (192.168.1.1), \u05e8\u05e9\u05ea\u05d5\u05ea IP (192.168.0.0/24) \u05d0\u05d5 \u05d8\u05d5\u05d5\u05d7\u05d9 IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "\u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05d9\u05dd" + }, + "step": { + "init": { + "data": { + "exclude": "\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05e8\u05e9\u05ea (\u05de\u05d5\u05e4\u05e8\u05d3\u05d5\u05ea \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e4\u05e1\u05d9\u05e7) \u05e9\u05dc\u05d0 \u05d9\u05d9\u05db\u05dc\u05dc\u05d5 \u05d1\u05e1\u05e8\u05d9\u05e7\u05d4", + "home_interval": "\u05de\u05e1\u05e4\u05e8 \u05de\u05d9\u05e0\u05d9\u05de\u05dc\u05d9 \u05e9\u05dc \u05d3\u05e7\u05d5\u05ea \u05d1\u05d9\u05df \u05e1\u05e8\u05d9\u05e7\u05d5\u05ea \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05e4\u05e2\u05d9\u05dc\u05d9\u05dd (\u05e9\u05d9\u05de\u05d5\u05e8 \u05e1\u05d5\u05dc\u05dc\u05d4)", + "hosts": "\u05db\u05ea\u05d5\u05d1\u05d5\u05ea \u05e8\u05e9\u05ea (\u05de\u05d5\u05e4\u05e8\u05d3\u05d5\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7) \u05dc\u05e1\u05e8\u05d9\u05e7\u05d4", + "interval_seconds": "\u05de\u05e8\u05d5\u05d5\u05d7 \u05d6\u05de\u05df \u05dc\u05e1\u05e8\u05d9\u05e7\u05d4", + "scan_options": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05e1\u05e8\u05d9\u05e7\u05d4 \u05d2\u05d5\u05dc\u05de\u05d9\u05d5\u05ea \u05d4\u05e0\u05d9\u05ea\u05e0\u05d5\u05ea \u05dc\u05d4\u05d2\u05d3\u05e8\u05d4 \u05e2\u05d1\u05d5\u05e8 Nmap", + "track_new_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05d3\u05e9\u05d9\u05dd" + }, + "description": "\u05e7\u05d1\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05d0\u05e8\u05d7\u05d9\u05dd \u05e9\u05d9\u05e1\u05e8\u05e7\u05d5 \u05e2\u05dc \u05d9\u05d3\u05d9 Nmap. \u05db\u05ea\u05d5\u05d1\u05ea \u05e8\u05e9\u05ea \u05d5\u05d0\u05d9 \u05d4\u05db\u05dc\u05dc\u05d4 \u05d9\u05db\u05d5\u05dc \u05dc\u05d4\u05d9\u05d5\u05ea \u05db\u05ea\u05d5\u05d1\u05d5\u05ea IP (192.168.1.1), \u05e8\u05e9\u05ea\u05d5\u05ea IP (192.168.0.0/24) \u05d0\u05d5 \u05d8\u05d5\u05d5\u05d7\u05d9 IP (192.168.1.0-32)." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/hu.json b/homeassistant/components/nmap_tracker/translations/hu.json new file mode 100644 index 00000000000..1b5dc9d029b --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/hu.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9pek" + }, + "step": { + "user": { + "data": { + "exclude": "H\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva), amelyeket kiz\u00e1r\u00e1sra ker\u00fclnek a vizsg\u00e1latb\u00f3l", + "home_interval": "Minim\u00e1lis percsz\u00e1m az akt\u00edv eszk\u00f6z\u00f6k vizsg\u00e1lata k\u00f6z\u00f6tt (akkumul\u00e1tor k\u00edm\u00e9l\u00e9se)", + "hosts": "H\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva) a beolvas\u00e1shoz", + "scan_options": "Nyersen konfigur\u00e1lhat\u00f3 szkennel\u00e9si lehet\u0151s\u00e9gek az Nmap sz\u00e1m\u00e1ra" + }, + "description": "\u00c1ll\u00edtsa be a gazdag\u00e9peket, hogy az Nmap ellen\u0151rizhesse \u0151ket. A h\u00e1l\u00f3zati c\u00edm IP-c\u00edm (192.168.1.1), IP-h\u00e1l\u00f3zat (192.168.0.0/24) vagy IP-tartom\u00e1ny (192.168.1.0-32) lehet." + } + } + }, + "options": { + "error": { + "invalid_hosts": "\u00c9rv\u00e9nytelen gazdag\u00e9p" + }, + "step": { + "init": { + "data": { + "exclude": "A szkennel\u00e9sb\u0151l kiz\u00e1rand\u00f3 h\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva)", + "home_interval": "Minim\u00e1lis percsz\u00e1m az akt\u00edv eszk\u00f6z\u00f6k vizsg\u00e1lata k\u00f6z\u00f6tt (akkumul\u00e1tor k\u00edm\u00e9l\u00e9se)", + "hosts": "H\u00e1l\u00f3zati c\u00edmek (vessz\u0151vel elv\u00e1lasztva) a beolvas\u00e1shoz", + "interval_seconds": "Szkennel\u00e9si intervallum", + "scan_options": "Nyersen konfigur\u00e1lhat\u00f3 beolvas\u00e1si lehet\u0151s\u00e9gek az Nmap sz\u00e1m\u00e1ra", + "track_new_devices": "\u00daj eszk\u00f6z\u00f6k nyomon k\u00f6vet\u00e9se" + }, + "description": "\u00c1ll\u00edtsa be a gazdag\u00e9peket, amelyeket a Nmap ellen\u0151riz. A h\u00e1l\u00f3zati c\u00edm IP-c\u00edm (192.168.1.1), IP-h\u00e1l\u00f3zat (192.168.0.0/24) vagy IP-tartom\u00e1ny (192.168.1.0-32) lehet." + } + } + }, + "title": "Nmap k\u00f6vet\u0151" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/id.json b/homeassistant/components/nmap_tracker/translations/id.json new file mode 100644 index 00000000000..d36ba84e8ac --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Lokasi sudah dikonfigurasi" + } + }, + "options": { + "step": { + "init": { + "data": { + "track_new_devices": "Lacak perangkat baru" + } + } + } + }, + "title": "Nmap Tracker" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/it.json b/homeassistant/components/nmap_tracker/translations/it.json new file mode 100644 index 00000000000..921d131c3bb --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/it.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "La posizione \u00e8 gi\u00e0 configurata" + }, + "error": { + "invalid_hosts": "Host non validi" + }, + "step": { + "user": { + "data": { + "exclude": "Indirizzi di rete (separati da virgole) da escludere dalla scansione", + "home_interval": "Numero minimo di minuti tra le scansioni dei dispositivi attivi (preserva la batteria)", + "hosts": "Indirizzi di rete (separati da virgole) da scansionare", + "scan_options": "Opzioni di scansione configurabili non elaborate per Nmap" + }, + "description": "Configura gli host da scansionare con Nmap. L'indirizzo di rete e le esclusioni possono essere indirizzi IP (192.168.1.1), reti IP (192.168.0.0/24) o intervalli IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Host non validi" + }, + "step": { + "init": { + "data": { + "exclude": "Indirizzi di rete (separati da virgole) da escludere dalla scansione", + "home_interval": "Numero minimo di minuti tra le scansioni dei dispositivi attivi (preserva la batteria)", + "hosts": "Indirizzi di rete (separati da virgole) da scansionare", + "interval_seconds": "Intervallo di scansione", + "scan_options": "Opzioni di scansione configurabili non elaborate per Nmap", + "track_new_devices": "Traccia nuovi dispositivi" + }, + "description": "Configura gli host da scansionare con Nmap. L'indirizzo di rete e le esclusioni possono essere indirizzi IP (192.168.1.1), reti IP (192.168.0.0/24) o intervalli IP (192.168.1.0-32)." + } + } + }, + "title": "Nmap Tracker" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/nl.json b/homeassistant/components/nmap_tracker/translations/nl.json index 9f52a6b9add..e9675dfc328 100644 --- a/homeassistant/components/nmap_tracker/translations/nl.json +++ b/homeassistant/components/nmap_tracker/translations/nl.json @@ -9,8 +9,30 @@ "step": { "user": { "data": { - "exclude": "Netwerkadressen (door komma's gescheiden) om uit te sluiten van scannen" - } + "exclude": "Netwerkadressen (door komma's gescheiden) om uit te sluiten van scannen", + "home_interval": "Minimum aantal minuten tussen scans van actieve apparaten (batterij sparen)", + "hosts": "Netwerkadressen (gescheiden door komma's) om te scannen", + "scan_options": "Ruwe configureerbare scanopties voor Nmap" + }, + "description": "Configureer hosts die moeten worden gescand door Nmap. Netwerkadres en uitsluitingen kunnen IP-adressen (192.168.1.1), IP-netwerken (192.168.0.0/24) of IP-bereiken (192.168.1.0-32) zijn." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Ongeldige hosts" + }, + "step": { + "init": { + "data": { + "exclude": "Netwerkadressen (door komma's gescheiden) om uit te sluiten van scannen", + "home_interval": "Minimum aantal minuten tussen scans van actieve apparaten (batterij sparen)", + "hosts": "Netwerkadressen (gescheiden door komma's) om te scannen", + "interval_seconds": "Scaninterval", + "scan_options": "Ruwe configureerbare scanopties voor Nmap", + "track_new_devices": "Volg nieuwe apparaten" + }, + "description": "Configureer hosts die moeten worden gescand door Nmap. Netwerkadres en uitsluitingen kunnen IP-adressen (192.168.1.1), IP-netwerken (192.168.0.0/24) of IP-bereiken (192.168.1.0-32) zijn." } } }, diff --git a/homeassistant/components/nmap_tracker/translations/pl.json b/homeassistant/components/nmap_tracker/translations/pl.json new file mode 100644 index 00000000000..dc16816609c --- /dev/null +++ b/homeassistant/components/nmap_tracker/translations/pl.json @@ -0,0 +1,40 @@ +{ + "config": { + "abort": { + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + }, + "error": { + "invalid_hosts": "Nieprawid\u0142owe hosta" + }, + "step": { + "user": { + "data": { + "exclude": "Adresy sieciowe (rozdzielone przecinkami) do wykluczenia ze skanowania", + "home_interval": "Minimalna liczba minut mi\u0119dzy skanami aktywnych urz\u0105dze\u0144 (oszcz\u0119dzanie baterii)", + "hosts": "Adresy sieciowe (oddzielone przecinkami) do skanowania", + "scan_options": "Surowe konfigurowalne opcje skanowania dla Nmap" + }, + "description": "Skonfiguruj hosta do skanowania przez Nmap. Adresy sieciowe i te wykluczone mog\u0105 by\u0107 adresami IP (192.168.1.1), sieciami IP (192.168.0.0/24) lub zakresami IP (192.168.1.0-32)." + } + } + }, + "options": { + "error": { + "invalid_hosts": "Nieprawid\u0142owe hosta" + }, + "step": { + "init": { + "data": { + "exclude": "Adresy sieciowe (rozdzielone przecinkami) do wykluczenia ze skanowania", + "home_interval": "Minimalna liczba minut mi\u0119dzy skanami aktywnych urz\u0105dze\u0144 (oszcz\u0119dzanie baterii)", + "hosts": "Adresy sieciowe (oddzielone przecinkami) do skanowania", + "interval_seconds": "Cz\u0119stotliwo\u015b\u0107 skanowania", + "scan_options": "Surowe konfigurowalne opcje skanowania dla Nmap", + "track_new_devices": "\u015aled\u017a nowe urz\u0105dzenia" + }, + "description": "Skonfiguruj hosta do skanowania przez Nmap. Adresy sieciowe i te wykluczone mog\u0105 by\u0107 adresami IP (192.168.1.1), sieciami IP (192.168.0.0/24) lub zakresami IP (192.168.1.0-32)." + } + } + }, + "title": "Nmap Tracker" +} \ No newline at end of file diff --git a/homeassistant/components/nmap_tracker/translations/ru.json b/homeassistant/components/nmap_tracker/translations/ru.json index dc488f64ca6..1a790358c73 100644 --- a/homeassistant/components/nmap_tracker/translations/ru.json +++ b/homeassistant/components/nmap_tracker/translations/ru.json @@ -28,7 +28,9 @@ "exclude": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u0438\u0437 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", "home_interval": "\u041c\u0438\u043d\u0438\u043c\u0430\u043b\u044c\u043d\u043e\u0435 \u043a\u043e\u043b\u0438\u0447\u0435\u0441\u0442\u0432\u043e \u043c\u0438\u043d\u0443\u0442 \u043c\u0435\u0436\u0434\u0443 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 \u0430\u043a\u0442\u0438\u0432\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 (\u044d\u043a\u043e\u043d\u043e\u043c\u0438\u044f \u0437\u0430\u0440\u044f\u0434\u0430 \u0431\u0430\u0442\u0430\u0440\u0435\u0438)", "hosts": "\u0421\u0435\u0442\u0435\u0432\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f (\u0447\u0435\u0440\u0435\u0437 \u0437\u0430\u043f\u044f\u0442\u0443\u044e)", - "scan_options": "\u041d\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0434\u043b\u044f Nmap" + "interval_seconds": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f", + "scan_options": "\u041d\u0435\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f \u0434\u043b\u044f Nmap", + "track_new_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0445\u043e\u0441\u0442\u043e\u0432 \u0434\u043b\u044f \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f Nmap. \u0421\u0435\u0442\u0435\u0432\u044b\u043c \u0430\u0434\u0440\u0435\u0441\u043e\u043c \u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f\u043c\u0438 \u043c\u043e\u0433\u0443\u0442 \u0431\u044b\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441\u0430 (192.168.1.1), IP-\u0441\u0435\u0442\u0438 (192.168.0.0/24) \u0438\u043b\u0438 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u044b IP-\u0430\u0434\u0440\u0435\u0441\u043e\u0432 (192.168.1.0-32)." } diff --git a/homeassistant/components/nmap_tracker/translations/zh-Hant.json b/homeassistant/components/nmap_tracker/translations/zh-Hant.json index ce06358efc7..a2c396fdec0 100644 --- a/homeassistant/components/nmap_tracker/translations/zh-Hant.json +++ b/homeassistant/components/nmap_tracker/translations/zh-Hant.json @@ -28,7 +28,9 @@ "exclude": "\u6392\u9664\u6383\u63cf\u7684\u7db2\u8def\u4f4d\u5740\uff08\u4ee5\u9017\u865f\u5206\u9694\uff09", "home_interval": "\u6383\u63cf\u6d3b\u52d5\u88dd\u7f6e\u7684\u6700\u4f4e\u9593\u9694\u5206\u9418\uff08\u8003\u91cf\u7701\u96fb\uff09", "hosts": "\u6240\u8981\u6383\u63cf\u7684\u7db2\u8def\u4f4d\u5740\uff08\u4ee5\u9017\u865f\u5206\u9694\uff09", - "scan_options": "Nmap \u539f\u59cb\u8a2d\u5b9a\u6383\u63cf\u9078\u9805" + "interval_seconds": "\u6383\u63cf\u9593\u8ddd", + "scan_options": "Nmap \u539f\u59cb\u8a2d\u5b9a\u6383\u63cf\u9078\u9805", + "track_new_devices": "\u8ffd\u8e64\u65b0\u88dd\u7f6e" }, "description": "\u8a2d\u5b9a Nmap \u6383\u63cf\u4e3b\u6a5f\u3002\u7db2\u8def\u4f4d\u5740\u8207\u6392\u9664\u4f4d\u5740\u53ef\u4ee5\u662f IP \u4f4d\u5740\uff08192.168.1.1\uff09\u3001IP \u7db2\u8def\uff08192.168.0.0/24\uff09\u6216 IP \u7bc4\u570d\uff08192.168.1.0-32\uff09\u3002" } diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py index 2e9f5c77fbf..97015eab38a 100644 --- a/homeassistant/components/no_ip/__init__.py +++ b/homeassistant/components/no_ip/__init__.py @@ -60,7 +60,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: password = config[DOMAIN].get(CONF_PASSWORD) timeout = config[DOMAIN].get(CONF_TIMEOUT) - auth_str = base64.b64encode(f"{user}:{password}".encode("utf-8")) + auth_str = base64.b64encode(f"{user}:{password}".encode()) session = hass.helpers.aiohttp_client.async_get_clientsession() diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 141086bb2be..aab10916514 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -1,6 +1,9 @@ """Support for Notion.""" +from __future__ import annotations + import asyncio from datetime import timedelta +from typing import Any from aionotion import async_get_client from aionotion.errors import InvalidCredentialsError, NotionError @@ -14,7 +17,6 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -34,14 +36,10 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) CONFIG_SCHEMA = cv.deprecated(DOMAIN) -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the Notion component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}} - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Notion as a config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}}) + if not entry.unique_id: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_USERNAME] @@ -51,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: client = await async_get_client( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session ) except InvalidCredentialsError: LOGGER.error("Invalid username and/or password") @@ -60,9 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - async def async_update(): + async def async_update() -> dict[str, dict[str, Any]]: """Get the latest data from the Notion API.""" - data = {"bridges": {}, "sensors": {}, "tasks": {}} + data: dict[str, dict[str, Any]] = {"bridges": {}, "sensors": {}, "tasks": {}} tasks = { "bridges": client.bridge.async_all(), "sensors": client.sensor.async_all(), @@ -116,7 +114,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_register_new_bridge( hass: HomeAssistant, bridge: dict, entry: ConfigEntry -): +) -> None: """Register a new bridge.""" device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( @@ -144,44 +142,12 @@ class NotionEntity(CoordinatorEntity): ) -> None: """Initialize the entity.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._bridge_id = bridge_id - self._device_class = device_class - self._name = name - self._sensor_id = sensor_id - self._state = None - self._system_id = system_id - self._unique_id = ( - f'{sensor_id}_{self.coordinator.data["tasks"][task_id]["task_type"]}' - ) - self.task_id = task_id - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.coordinator.last_update_success - and self.task_id in self.coordinator.data["tasks"] - and self._state - ) + self._attr_device_class = device_class - @property - def device_class(self) -> str: - """Return the device class.""" - return self._device_class - - @property - def extra_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attrs - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - bridge = self.coordinator.data["bridges"].get(self._bridge_id, {}) - sensor = self.coordinator.data["sensors"][self._sensor_id] - - return { + bridge = self.coordinator.data["bridges"].get(bridge_id, {}) + sensor = self.coordinator.data["sensors"][sensor_id] + self._attr_device_info = { "identifiers": {(DOMAIN, sensor["hardware_id"])}, "manufacturer": "Silicon Labs", "model": sensor["hardware_revision"], @@ -190,16 +156,23 @@ class NotionEntity(CoordinatorEntity): "via_device": (DOMAIN, bridge.get("hardware_id")), } - @property - def name(self) -> str: - """Return the name of the entity.""" - sensor = self.coordinator.data["sensors"][self._sensor_id] - return f'{sensor["name"]}: {self._name}' + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_name = f'{sensor["name"]}: {name}' + self._attr_unique_id = ( + f'{sensor_id}_{coordinator.data["tasks"][task_id]["task_type"]}' + ) + self._bridge_id = bridge_id + self._sensor_id = sensor_id + self._system_id = system_id + self._task_id = task_id @property - def unique_id(self) -> str: - """Return a unique, unchanging string that represents this entity.""" - return self._unique_id + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.coordinator.last_update_success + and self._task_id in self.coordinator.data["tasks"] + ) async def _async_update_bridge_id(self) -> None: """Update the entity's bridge ID if it has changed. @@ -220,13 +193,16 @@ class NotionEntity(CoordinatorEntity): self._bridge_id = sensor["bridge"]["id"] device_registry = await dr.async_get_registry(self.hass) + this_device = device_registry.async_get_device( + {(DOMAIN, sensor["hardware_id"])} + ) bridge = self.coordinator.data["bridges"][self._bridge_id] bridge_device = device_registry.async_get_device( {(DOMAIN, bridge["hardware_id"])} ) - this_device = device_registry.async_get_device( - {(DOMAIN, sensor["hardware_id"])} - ) + + if not bridge_device or not this_device: + return device_registry.async_update_device( this_device.id, via_device_id=bridge_device.id @@ -238,15 +214,15 @@ class NotionEntity(CoordinatorEntity): raise NotImplementedError @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" - if self.task_id in self.coordinator.data["tasks"]: + if self._task_id in self.coordinator.data["tasks"]: self.hass.async_create_task(self._async_update_bridge_id()) self._async_update_from_latest_data() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() self._async_update_from_latest_data() diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 168e35a3a97..d3b1d8e3ef2 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -44,7 +44,7 @@ BINARY_SENSOR_TYPES = { async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -): +) -> None: """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] @@ -77,24 +77,19 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self.task_id] + task = self.coordinator.data["tasks"][self._task_id] if "value" in task["status"]: - self._state = task["status"]["value"] + state = task["status"]["value"] elif task["status"].get("insights", {}).get("primary"): - self._state = task["status"]["insights"]["primary"]["to_state"] + state = task["status"]["insights"]["primary"]["to_state"] else: LOGGER.warning("Unknown data payload: %s", task["status"]) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the sensor is on or off.""" - task = self.coordinator.data["tasks"][self.task_id] + state = None if task["task_type"] == SENSOR_BATTERY: - return self._state == "critical" - if task["task_type"] in ( + self._attr_is_on = state == "critical" + elif task["task_type"] in ( SENSOR_DOOR, SENSOR_GARAGE_DOOR, SENSOR_SAFE, @@ -102,10 +97,10 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity): SENSOR_WINDOW_HINGED_HORIZONTAL, SENSOR_WINDOW_HINGED_VERTICAL, ): - return self._state != "closed" - if task["task_type"] == SENSOR_LEAK: - return self._state != "no_leak" - if task["task_type"] == SENSOR_MISSING: - return self._state == "not_missing" - if task["task_type"] == SENSOR_SMOKE_CO: - return self._state != "no_alarm" + self._attr_is_on = state != "closed" + elif task["task_type"] == SENSOR_LEAK: + self._attr_is_on = state != "no_leak" + elif task["task_type"] == SENSOR_MISSING: + self._attr_is_on = state == "not_missing" + elif task["task_type"] == SENSOR_SMOKE_CO: + self._attr_is_on = state != "no_alarm" diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 13386a67c02..ad6d8eb9519 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,10 +1,13 @@ """Config flow to configure the Notion integration.""" +from __future__ import annotations + from aionotion import async_get_client from aionotion.errors import NotionError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -15,19 +18,21 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" self.data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, str] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", data_schema=self.data_schema, errors=errors or {} ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return await self._show_form() @@ -39,7 +44,7 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: await async_get_client( - user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=session ) except NotionError: return await self._show_form({"base": "invalid_auth"}) diff --git a/homeassistant/components/notion/manifest.json b/homeassistant/components/notion/manifest.json index 191f66ee59d..378d6442e31 100644 --- a/homeassistant/components/notion/manifest.json +++ b/homeassistant/components/notion/manifest.json @@ -3,7 +3,7 @@ "name": "Notion", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/notion", - "requirements": ["aionotion==1.1.0"], + "requirements": ["aionotion==3.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py index 2494ed2d2e8..48b9a25f783 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -1,7 +1,7 @@ """Support for Notion sensors.""" from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -9,12 +9,14 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import NotionEntity from .const import DATA_COORDINATOR, DOMAIN, LOGGER, SENSOR_TEMPERATURE -SENSOR_TYPES = {SENSOR_TEMPERATURE: ("Temperature", "temperature", TEMP_CELSIUS)} +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ("Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +} async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -): +) -> None: """Set up Notion sensors based on a config entry.""" coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] @@ -61,25 +63,15 @@ class NotionSensor(NotionEntity, SensorEntity): coordinator, task_id, sensor_id, bridge_id, system_id, name, device_class ) - self._unit = unit - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self) -> str: - """Return the unit of measurement.""" - return self._unit + self._attr_unit_of_measurement = unit @callback def _async_update_from_latest_data(self) -> None: """Fetch new state data for the sensor.""" - task = self.coordinator.data["tasks"][self.task_id] + task = self.coordinator.data["tasks"][self._task_id] if task["task_type"] == SENSOR_TEMPERATURE: - self._state = round(float(task["status"]["value"]), 1) + self._attr_state = round(float(task["status"]["value"]), 1) else: LOGGER.error( "Unknown task type: %s: %s", diff --git a/homeassistant/components/nuheat/translations/de.json b/homeassistant/components/nuheat/translations/de.json index 8599f7fe1b5..0ab69dd4557 100644 --- a/homeassistant/components/nuheat/translations/de.json +++ b/homeassistant/components/nuheat/translations/de.json @@ -16,8 +16,8 @@ "serial_number": "Seriennummer des Thermostats.", "username": "Benutzername" }, - "description": "Sie m\u00fcssen die numerische Seriennummer oder ID Ihres Thermostats erhalten, indem Sie sich bei https://MyNuHeat.com anmelden und Ihre Thermostate ausw\u00e4hlen.", - "title": "Stellen Sie eine Verbindung zu NuHeat her" + "description": "Du musst die numerische Seriennummer oder ID deines Thermostats erhalten, indem du dich bei https://MyNuHeat.com anmeldest und deine Thermostate ausw\u00e4hlst.", + "title": "Stelle eine Verbindung zu NuHeat her" } } } diff --git a/homeassistant/components/nuki/translations/hu.json b/homeassistant/components/nuki/translations/hu.json index 4f0b1a29738..7a0b6b6159e 100644 --- a/homeassistant/components/nuki/translations/hu.json +++ b/homeassistant/components/nuki/translations/hu.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt" + }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "reauth_confirm": { + "data": { + "token": "Hozz\u00e1f\u00e9r\u00e9si token" + }, + "description": "A Nuki integr\u00e1ci\u00f3nak \u00fajb\u00f3l hiteles\u00edtenie kell a h\u00edddal.", + "title": "Az integr\u00e1ci\u00f3 \u00fajb\u00f3li azonos\u00edt\u00e1sa" + }, "user": { "data": { "host": "Hoszt", diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index cbfdea7fa11..88ba5cf8b41 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -1,6 +1,7 @@ """Component to allow numeric input for platforms.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -13,7 +14,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -66,9 +67,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class NumberEntityDescription(EntityDescription): + """A class that describes number entities.""" + + class NumberEntity(Entity): """Representation of a Number entity.""" + entity_description: NumberEntityDescription _attr_max_value: float = DEFAULT_MAX_VALUE _attr_min_value: float = DEFAULT_MIN_VALUE _attr_state: None = None diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 890ac3697dd..1f5fecdd219 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -7,14 +7,14 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_VOLTAGE, ) from homeassistant.const import ( - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_HERTZ, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS, - VOLT, ) DOMAIN = "nut" @@ -80,8 +80,8 @@ SENSOR_TYPES = { "ups.display.language": ["Language", "", "mdi:information-outline", None], "ups.contacts": ["External Contacts", "", "mdi:information-outline", None], "ups.efficiency": ["Efficiency", PERCENTAGE, "mdi:gauge", None], - "ups.power": ["Current Apparent Power", ELECTRICAL_VOLT_AMPERE, "mdi:flash", None], - "ups.power.nominal": ["Nominal Power", ELECTRICAL_VOLT_AMPERE, "mdi:flash", None], + "ups.power": ["Current Apparent Power", POWER_VOLT_AMPERE, "mdi:flash", None], + "ups.power.nominal": ["Nominal Power", POWER_VOLT_AMPERE, "mdi:flash", None], "ups.realpower": [ "Current Real Power", POWER_WATT, @@ -121,25 +121,40 @@ SENSOR_TYPES = { None, ], "battery.charger.status": ["Charging Status", "", "mdi:information-outline", None], - "battery.voltage": ["Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], - "battery.voltage.nominal": [ - "Nominal Battery Voltage", - VOLT, + "battery.voltage": [ + "Battery Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], + "battery.voltage.nominal": [ + "Nominal Battery Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], + "battery.voltage.low": [ + "Low Battery Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], + "battery.voltage.high": [ + "High Battery Voltage", + ELECTRIC_POTENTIAL_VOLT, None, DEVICE_CLASS_VOLTAGE, ], - "battery.voltage.low": ["Low Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], - "battery.voltage.high": ["High Battery Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], "battery.capacity": ["Battery Capacity", "Ah", "mdi:flash", None], "battery.current": [ "Battery Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], "battery.current.total": [ "Total Battery Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], @@ -184,18 +199,33 @@ SENSOR_TYPES = { "mdi:information-outline", None, ], - "input.transfer.low": ["Low Voltage Transfer", VOLT, None, DEVICE_CLASS_VOLTAGE], - "input.transfer.high": ["High Voltage Transfer", VOLT, None, DEVICE_CLASS_VOLTAGE], + "input.transfer.low": [ + "Low Voltage Transfer", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], + "input.transfer.high": [ + "High Voltage Transfer", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], "input.transfer.reason": [ "Voltage Transfer Reason", "", "mdi:information-outline", None, ], - "input.voltage": ["Input Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], + "input.voltage": [ + "Input Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], "input.voltage.nominal": [ "Nominal Input Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, None, DEVICE_CLASS_VOLTAGE, ], @@ -212,17 +242,22 @@ SENSOR_TYPES = { "mdi:information-outline", None, ], - "output.current": ["Output Current", ELECTRICAL_CURRENT_AMPERE, "mdi:flash", None], + "output.current": ["Output Current", ELECTRIC_CURRENT_AMPERE, "mdi:flash", None], "output.current.nominal": [ "Nominal Output Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:flash", None, ], - "output.voltage": ["Output Voltage", VOLT, None, DEVICE_CLASS_VOLTAGE], + "output.voltage": [ + "Output Voltage", + ELECTRIC_POTENTIAL_VOLT, + None, + DEVICE_CLASS_VOLTAGE, + ], "output.voltage.nominal": [ "Nominal Output Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, None, DEVICE_CLASS_VOLTAGE, ], diff --git a/homeassistant/components/nut/translations/de.json b/homeassistant/components/nut/translations/de.json index 990f21523b6..e82dca2506b 100644 --- a/homeassistant/components/nut/translations/de.json +++ b/homeassistant/components/nut/translations/de.json @@ -12,7 +12,7 @@ "data": { "resources": "Ressourcen" }, - "title": "W\u00e4hlen Sie die zu \u00fcberwachenden Ressourcen aus" + "title": "W\u00e4hle die zu \u00fcberwachenden Ressourcen aus" }, "ups": { "data": { @@ -28,7 +28,7 @@ "port": "Port", "username": "Benutzername" }, - "title": "Stellen Sie eine Verbindung zum NUT-Server her" + "title": "Stelle eine Verbindung zum NUT-Server her" } } }, @@ -43,7 +43,7 @@ "resources": "Ressourcen", "scan_interval": "Scan-Intervall (Sekunden)" }, - "description": "W\u00e4hlen Sie Sensorressourcen." + "description": "W\u00e4hle Sensorressourcen." } } } diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index f82a70ea4e0..6e08ef408d3 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -1,6 +1,10 @@ """Constants for National Weather Service Integration.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, @@ -17,7 +21,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY_VARIANT, ) from homeassistant.const import ( - ATTR_DEVICE_CLASS, DEGREE, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -40,11 +43,6 @@ ATTRIBUTION = "Data from National Weather Service/NOAA" ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" ATTR_FORECAST_DAYTIME = "daytime" -ATTR_ICON = "icon" -ATTR_LABEL = "label" -ATTR_UNIT = "unit" -ATTR_UNIT_CONVERT = "unit_convert" -ATTR_UNIT_CONVERT_METHOD = "unit_convert_method" CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: [ @@ -101,82 +99,101 @@ COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" OBSERVATION_VALID_TIME = timedelta(minutes=20) FORECAST_VALID_TIME = timedelta(minutes=45) -SENSOR_TYPES = { - "dewpoint": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Dew Point", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "temperature": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Temperature", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "windChill": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Wind Chill", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "heatIndex": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, - ATTR_ICON: None, - ATTR_LABEL: "Heat Index", - ATTR_UNIT: TEMP_CELSIUS, - ATTR_UNIT_CONVERT: TEMP_CELSIUS, - }, - "relativeHumidity": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, - ATTR_ICON: None, - ATTR_LABEL: "Relative Humidity", - ATTR_UNIT: PERCENTAGE, - ATTR_UNIT_CONVERT: PERCENTAGE, - }, - "windSpeed": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Speed", - ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, - }, - "windGust": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:weather-windy", - ATTR_LABEL: "Wind Gust", - ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, - ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, - }, - "windDirection": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:compass-rose", - ATTR_LABEL: "Wind Direction", - ATTR_UNIT: DEGREE, - ATTR_UNIT_CONVERT: DEGREE, - }, - "barometricPressure": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_LABEL: "Barometric Pressure", - ATTR_UNIT: PRESSURE_PA, - ATTR_UNIT_CONVERT: PRESSURE_INHG, - }, - "seaLevelPressure": { - ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, - ATTR_ICON: None, - ATTR_LABEL: "Sea Level Pressure", - ATTR_UNIT: PRESSURE_PA, - ATTR_UNIT_CONVERT: PRESSURE_INHG, - }, - "visibility": { - ATTR_DEVICE_CLASS: None, - ATTR_ICON: "mdi:eye", - ATTR_LABEL: "Visibility", - ATTR_UNIT: LENGTH_METERS, - ATTR_UNIT_CONVERT: LENGTH_MILES, - }, -} + +@dataclass +class NWSSensorEntityDescription(SensorEntityDescription): + """Class describing NWSSensor entities.""" + + unit_convert: str | None = None + + +SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = ( + NWSSensorEntityDescription( + key="dewpoint", + name="Dew Point", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + NWSSensorEntityDescription( + key="temperature", + name="Temperature", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + NWSSensorEntityDescription( + key="windChill", + name="Wind Chill", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + NWSSensorEntityDescription( + key="heatIndex", + name="Heat Index", + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + unit_of_measurement=TEMP_CELSIUS, + unit_convert=TEMP_CELSIUS, + ), + NWSSensorEntityDescription( + key="relativeHumidity", + name="Relative Humidity", + icon=None, + device_class=DEVICE_CLASS_HUMIDITY, + unit_of_measurement=PERCENTAGE, + unit_convert=PERCENTAGE, + ), + NWSSensorEntityDescription( + key="windSpeed", + name="Wind Speed", + icon="mdi:weather-windy", + device_class=None, + unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + unit_convert=SPEED_MILES_PER_HOUR, + ), + NWSSensorEntityDescription( + key="windGust", + name="Wind Gust", + icon="mdi:weather-windy", + device_class=None, + unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, + unit_convert=SPEED_MILES_PER_HOUR, + ), + NWSSensorEntityDescription( + key="windDirection", + name="Wind Direction", + icon="mdi:compass-rose", + device_class=None, + unit_of_measurement=DEGREE, + unit_convert=DEGREE, + ), + NWSSensorEntityDescription( + key="barometricPressure", + name="Barometric Pressure", + icon=None, + device_class=DEVICE_CLASS_PRESSURE, + unit_of_measurement=PRESSURE_PA, + unit_convert=PRESSURE_INHG, + ), + NWSSensorEntityDescription( + key="seaLevelPressure", + name="Sea Level Pressure", + icon=None, + device_class=DEVICE_CLASS_PRESSURE, + unit_of_measurement=PRESSURE_PA, + unit_convert=PRESSURE_INHG, + ), + NWSSensorEntityDescription( + key="visibility", + name="Visibility", + icon="mdi:eye", + device_class=None, + unit_of_measurement=LENGTH_METERS, + unit_convert=LENGTH_MILES, + ), +) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index bff5cdca589..409856831a2 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -2,7 +2,6 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_DEVICE_CLASS, CONF_LATITUDE, CONF_LONGITUDE, LENGTH_KILOMETERS, @@ -14,6 +13,7 @@ from homeassistant.const import ( SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow @@ -21,10 +21,6 @@ from homeassistant.util.pressure import convert as convert_pressure from . import base_unique_id from .const import ( - ATTR_ICON, - ATTR_LABEL, - ATTR_UNIT, - ATTR_UNIT_CONVERT, ATTRIBUTION, CONF_STATION, COORDINATOR_OBSERVATION, @@ -32,6 +28,7 @@ from .const import ( NWS_DATA, OBSERVATION_VALID_TIME, SENSOR_TYPES, + NWSSensorEntityDescription, ) PARALLEL_UPDATES = 0 @@ -42,101 +39,71 @@ async def async_setup_entry(hass, entry, async_add_entities): hass_data = hass.data[DOMAIN][entry.entry_id] station = entry.data[CONF_STATION] - entities = [] - for sensor_type, sensor_data in SENSOR_TYPES.items(): - if hass.config.units.is_metric: - unit = sensor_data[ATTR_UNIT] - else: - unit = sensor_data[ATTR_UNIT_CONVERT] - entities.append( - NWSSensor( - entry.data, - hass_data, - sensor_type, - station, - sensor_data[ATTR_LABEL], - sensor_data[ATTR_ICON], - sensor_data[ATTR_DEVICE_CLASS], - unit, - ), + async_add_entities( + NWSSensor( + hass=hass, + entry_data=entry.data, + hass_data=hass_data, + description=description, + station=station, ) - - async_add_entities(entities, False) + for description in SENSOR_TYPES + ) class NWSSensor(CoordinatorEntity, SensorEntity): """An NWS Sensor Entity.""" + entity_description: NWSSensorEntityDescription + def __init__( self, + hass: HomeAssistant, entry_data, hass_data, - sensor_type, + description: NWSSensorEntityDescription, station, - label, - icon, - device_class, - unit, ): """Initialise the platform with a data instance.""" super().__init__(hass_data[COORDINATOR_OBSERVATION]) self._nws = hass_data[NWS_DATA] self._latitude = entry_data[CONF_LATITUDE] self._longitude = entry_data[CONF_LONGITUDE] - self._type = sensor_type - self._station = station - self._label = label - self._icon = icon - self._device_class = device_class - self._unit = unit + self.entity_description = description + + self._attr_name = f"{station} {description.name}" + if not hass.config.units.is_metric: + self._attr_unit_of_measurement = description.unit_convert @property def state(self): """Return the state.""" - value = self._nws.observation.get(self._type) + value = self._nws.observation.get(self.entity_description.key) if value is None: return None - if self._unit == SPEED_MILES_PER_HOUR: + # Set alias to unit property -> prevent unnecessary hasattr calls + unit_of_measurement = self.unit_of_measurement + if unit_of_measurement == SPEED_MILES_PER_HOUR: return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) - if self._unit == LENGTH_MILES: + if unit_of_measurement == LENGTH_MILES: return round(convert_distance(value, LENGTH_METERS, LENGTH_MILES)) - if self._unit == PRESSURE_INHG: + if unit_of_measurement == PRESSURE_INHG: return round(convert_pressure(value, PRESSURE_PA, PRESSURE_INHG), 2) - if self._unit == TEMP_CELSIUS: + if unit_of_measurement == TEMP_CELSIUS: return round(value, 1) - if self._unit == PERCENTAGE: + if unit_of_measurement == PERCENTAGE: return round(value) return value - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - @property def device_state_attributes(self): """Return the attribution.""" return {ATTR_ATTRIBUTION: ATTRIBUTION} - @property - def name(self): - """Return the name of the station.""" - return f"{self._station} {self._label}" - @property def unique_id(self): """Return a unique_id for this entity.""" - return f"{base_unique_id(self._latitude, self._longitude)}_{self._type}" + return f"{base_unique_id(self._latitude, self._longitude)}_{self.entity_description.key}" @property def available(self): diff --git a/homeassistant/components/nws/translations/ar.json b/homeassistant/components/nws/translations/ar.json new file mode 100644 index 00000000000..98ff43c0aec --- /dev/null +++ b/homeassistant/components/nws/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u0627\u0644\u0627\u062a\u0635\u0627\u0644 \u0628\u062e\u062f\u0645\u0629 \u0627\u0644\u0623\u0631\u0635\u0627\u062f \u0627\u0644\u062c\u0648\u064a\u0629 \u0627\u0644\u0648\u0637\u0646\u064a\u0629" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nws/translations/de.json b/homeassistant/components/nws/translations/de.json index b6899e34789..3851684def5 100644 --- a/homeassistant/components/nws/translations/de.json +++ b/homeassistant/components/nws/translations/de.json @@ -16,7 +16,7 @@ "station": "METAR Stationscode" }, "description": "Wenn kein METAR-Stationscode angegeben wird, werden der Breiten- und L\u00e4ngengrad verwendet, um die n\u00e4chstgelegene Station zu finden. Im Moment kann ein API-Schl\u00fcssel alles sein. Es wird empfohlen, eine g\u00fcltige E-Mail-Adresse zu verwenden.", - "title": "Stellen Sie eine Verbindung zum Nationalen Wetterdienst her" + "title": "Stelle eine Verbindung zum Nationalen Wetterdienst her" } } } diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index 71419dae641..8aa18502ba3 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -75,7 +75,9 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user(user_input) - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -129,7 +131,7 @@ class NZBGetOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: ConfigType | None = None): + async def async_step_init(self, user_input: dict[str, Any] | None = None): """Manage NZBGet options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/nzbget/translations/de.json b/homeassistant/components/nzbget/translations/de.json index 74d073ce292..1b1575769bc 100644 --- a/homeassistant/components/nzbget/translations/de.json +++ b/homeassistant/components/nzbget/translations/de.json @@ -15,9 +15,9 @@ "name": "Name", "password": "Passwort", "port": "Port", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL-Zertifikat verfizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "title": "Mit NZBGet verbinden" } diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 918f0258f78..396a18318f2 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -241,7 +241,7 @@ class OctoPrintAPI: return response.json() except requests.ConnectionError as exc_con: - log_string = "Failed to connect to Octoprint server. Error: %s" % exc_con + log_string = f"Failed to connect to Octoprint server. Error: {exc_con}" if not self.available_error_logged: _LOGGER.error(log_string) @@ -254,7 +254,7 @@ class OctoPrintAPI: except requests.HTTPError as ex_http: status_code = ex_http.response.status_code - log_string = "Failed to update OctoPrint status. Error: %s" % ex_http + log_string = f"Failed to update OctoPrint status. Error: {ex_http}" # Only log the first failure if endpoint == "job": log_string = f"Endpoint: job {log_string}" diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py index 8c08b026b28..c91cf429c94 100644 --- a/homeassistant/components/ombi/sensor.py +++ b/homeassistant/components/ombi/sensor.py @@ -22,10 +22,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ombi = hass.data[DOMAIN]["instance"] - for sensor in SENSOR_TYPES: + for sensor, sensor_val in SENSOR_TYPES.items(): sensor_label = sensor - sensor_type = SENSOR_TYPES[sensor]["type"] - sensor_icon = SENSOR_TYPES[sensor]["icon"] + sensor_type = sensor_val["type"] + sensor_icon = sensor_val["icon"] sensors.append(OmbiSensor(sensor_label, sensor_type, ombi, sensor_icon)) add_entities(sensors, True) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index beec071b192..1f8de082868 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -2,6 +2,7 @@ from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, + ELECTRIC_POTENTIAL_MILLIVOLT, MASS_GRAMS, PERCENTAGE, TEMP_CELSIUS, @@ -342,7 +343,7 @@ SENSOR_TYPES = { "kind": "csad_orp", "device_class": None, "icon": "mdi:gauge", - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "guard_condition": [ {"orp": ""}, ], diff --git a/homeassistant/components/omnilogic/translations/hu.json b/homeassistant/components/omnilogic/translations/hu.json index 129bb041b42..a0a1facddd5 100644 --- a/homeassistant/components/omnilogic/translations/hu.json +++ b/homeassistant/components/omnilogic/translations/hu.json @@ -21,6 +21,7 @@ "step": { "init": { "data": { + "ph_offset": "pH-eltol\u00e1s (pozit\u00edv vagy negat\u00edv)", "polling_interval": "Lek\u00e9rdez\u00e9si id\u0151k\u00f6z (m\u00e1sodpercben)" } } diff --git a/homeassistant/components/omnilogic/translations/id.json b/homeassistant/components/omnilogic/translations/id.json index ed19cc68cf8..504d803fb13 100644 --- a/homeassistant/components/omnilogic/translations/id.json +++ b/homeassistant/components/omnilogic/translations/id.json @@ -21,6 +21,7 @@ "step": { "init": { "data": { + "ph_offset": "Ofset pH (positif atau negatif)", "polling_interval": "Interval polling (dalam detik)" } } diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 2428862cb31..26a61ddfe4c 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -1,15 +1,18 @@ """Platform for sensor integration.""" +from __future__ import annotations + from datetime import timedelta import logging from ondilo import OndiloError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, TEMP_CELSIUS, ) @@ -21,25 +24,58 @@ from homeassistant.helpers.update_coordinator import ( from .const import DOMAIN -SENSOR_TYPES = { - "temperature": [ - "Temperature", - TEMP_CELSIUS, - None, - DEVICE_CLASS_TEMPERATURE, - ], - "orp": ["Oxydo Reduction Potential", "mV", "mdi:pool", None], - "ph": ["pH", "", "mdi:pool", None], - "tds": ["TDS", CONCENTRATION_PARTS_PER_MILLION, "mdi:pool", None], - "battery": ["Battery", PERCENTAGE, None, DEVICE_CLASS_BATTERY], - "rssi": [ - "RSSI", - PERCENTAGE, - None, - DEVICE_CLASS_SIGNAL_STRENGTH, - ], - "salt": ["Salt", "mg/L", "mdi:pool", None], -} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="temperature", + name="Temperature", + unit_of_measurement=TEMP_CELSIUS, + icon=None, + device_class=DEVICE_CLASS_TEMPERATURE, + ), + SensorEntityDescription( + key="orp", + name="Oxydo Reduction Potential", + unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, + icon="mdi:pool", + device_class=None, + ), + SensorEntityDescription( + key="ph", + name="pH", + unit_of_measurement=None, + icon="mdi:pool", + device_class=None, + ), + SensorEntityDescription( + key="tds", + name="TDS", + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:pool", + device_class=None, + ), + SensorEntityDescription( + key="battery", + name="Battery", + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_BATTERY, + ), + SensorEntityDescription( + key="rssi", + name="RSSI", + unit_of_measurement=PERCENTAGE, + icon=None, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + ), + SensorEntityDescription( + key="salt", + name="Salt", + unit_of_measurement="mg/L", + icon="mdi:pool", + device_class=None, + ), +) + SCAN_INTERVAL = timedelta(hours=1) _LOGGER = logging.getLogger(__name__) @@ -77,9 +113,14 @@ async def async_setup_entry(hass, entry, async_add_entities): entities = [] for poolidx, pool in enumerate(coordinator.data): - for sensor_idx, sensor in enumerate(pool["sensors"]): - if sensor["data_type"] in SENSOR_TYPES: - entities.append(OndiloICO(coordinator, poolidx, sensor_idx)) + entities.extend( + [ + OndiloICO(coordinator, poolidx, description) + for sensor in pool["sensors"] + for description in SENSOR_TYPES + if description.key == sensor["data_type"] + ] + ) async_add_entities(entities) @@ -88,21 +129,21 @@ class OndiloICO(CoordinatorEntity, SensorEntity): """Representation of a Sensor.""" def __init__( - self, coordinator: DataUpdateCoordinator, poolidx: int, sensor_idx: int + self, + coordinator: DataUpdateCoordinator, + poolidx: int, + description: SensorEntityDescription, ) -> None: """Initialize sensor entity with data from coordinator.""" super().__init__(coordinator) + self.entity_description = description self._poolid = self.coordinator.data[poolidx]["id"] pooldata = self._pooldata() - self._data_type = pooldata["sensors"][sensor_idx]["data_type"] - self._unique_id = f"{pooldata['ICO']['serial_number']}-{self._data_type}" + self._unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" self._device_name = pooldata["name"] - self._name = f"{self._device_name} {SENSOR_TYPES[self._data_type][0]}" - self._device_class = SENSOR_TYPES[self._data_type][3] - self._icon = SENSOR_TYPES[self._data_type][2] - self._unit = SENSOR_TYPES[self._data_type][1] + self._name = f"{self._device_name} {description.name}" def _pooldata(self): """Get pool data dict.""" @@ -117,36 +158,16 @@ class OndiloICO(CoordinatorEntity, SensorEntity): ( data_type for data_type in self._pooldata()["sensors"] - if data_type["data_type"] == self._data_type + if data_type["data_type"] == self.entity_description.key ), None, ) - @property - def name(self): - """Name of the sensor.""" - return self._name - @property def state(self): """Last value of the sensor.""" return self._devdata()["value"] - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - - @property - def unit_of_measurement(self): - """Return the Unit of the sensor's measurement.""" - return self._unit - @property def unique_id(self): """Return the unique ID of this entity.""" diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 5ba813ce368..b99f095de7b 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -53,10 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Start platforms and cleanup devices.""" # wait until all required platforms are ready await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) await cleanup_registry() diff --git a/homeassistant/components/onewire/const.py b/homeassistant/components/onewire/const.py index d2c712c26c5..9112bf5e8f6 100644 --- a/homeassistant/components/onewire/const.py +++ b/homeassistant/components/onewire/const.py @@ -11,12 +11,12 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, PRESSURE_MBAR, TEMP_CELSIUS, - VOLT, ) CONF_MOUNT_DIR = "mount_dir" @@ -55,8 +55,8 @@ SENSOR_TYPES: dict[str, list[str | None]] = { SENSOR_TYPE_WETNESS: [PERCENTAGE, DEVICE_CLASS_HUMIDITY], SENSOR_TYPE_MOISTURE: [PRESSURE_CBAR, DEVICE_CLASS_PRESSURE], SENSOR_TYPE_COUNT: ["count", None], - SENSOR_TYPE_VOLTAGE: [VOLT, DEVICE_CLASS_VOLTAGE], - SENSOR_TYPE_CURRENT: [ELECTRICAL_CURRENT_AMPERE, DEVICE_CLASS_CURRENT], + SENSOR_TYPE_VOLTAGE: [ELECTRIC_POTENTIAL_VOLT, DEVICE_CLASS_VOLTAGE], + SENSOR_TYPE_CURRENT: [ELECTRIC_CURRENT_AMPERE, DEVICE_CLASS_CURRENT], SENSOR_TYPE_SENSED: [None, None], SWITCH_TYPE_LATCH: [None, None], SWITCH_TYPE_PIO: [None, None], diff --git a/homeassistant/components/onewire/translations/hu.json b/homeassistant/components/onewire/translations/hu.json index 662475dde2c..e2c7ffa8c03 100644 --- a/homeassistant/components/onewire/translations/hu.json +++ b/homeassistant/components/onewire/translations/hu.json @@ -12,7 +12,8 @@ "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "Owserver adatok be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index 2e4b6eff6da..ef20c1054f3 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -319,8 +319,10 @@ class OnkyoDevice(MediaPlayerEntity): preset_raw = self.command("preset query") if self._audio_info_supported: audio_information_raw = self.command("audio-information query") + self._parse_audio_information(audio_information_raw) if self._video_info_supported: video_information_raw = self.command("video-information query") + self._parse_video_information(video_information_raw) if not (volume_raw and mute_raw and current_source_raw): return @@ -343,9 +345,6 @@ class OnkyoDevice(MediaPlayerEntity): self._receiver_max_volume * self._max_volume / 100 ) - self._parse_audio_information(audio_information_raw) - self._parse_video_information(video_information_raw) - if not hdmi_out_raw: return self._attributes[ATTR_VIDEO_OUT] = ",".join(hdmi_out_raw[1]) diff --git a/homeassistant/components/onvif/translations/ar.json b/homeassistant/components/onvif/translations/ar.json new file mode 100644 index 00000000000..5135d42e795 --- /dev/null +++ b/homeassistant/components/onvif/translations/ar.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "configure": { + "data": { + "host": "\u0627\u0644\u0645\u0636\u064a\u0641", + "name": "\u0627\u0644\u0627\u0633\u0645", + "password": "\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631" + }, + "title": "\u062a\u0643\u0648\u064a\u0646 \u062c\u0647\u0627\u0632 ONVIF" + }, + "user": { + "data": { + "auto": "\u0628\u062d\u062b \u062a\u0644\u0642\u0627\u0626\u064a" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/ca.json b/homeassistant/components/onvif/translations/ca.json index a364552f9b7..7ede84d4845 100644 --- a/homeassistant/components/onvif/translations/ca.json +++ b/homeassistant/components/onvif/translations/ca.json @@ -18,6 +18,16 @@ }, "title": "Configuraci\u00f3 d'autenticaci\u00f3" }, + "configure": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + }, + "title": "Configuraci\u00f3 de dispositiu ONVIF" + }, "configure_profile": { "data": { "include": "Crea entitat de c\u00e0mera" @@ -40,6 +50,9 @@ "title": "Configura el dispositiu ONVIF" }, "user": { + "data": { + "auto": "Cerca autom\u00e0ticament" + }, "description": "En fer clic a envia, es cercaran a la xarxa dispositius ONVIF que suportin perfils S.\n\nAlguns fabricants han comen\u00e7at a desactivar ONVIF per defecte. Comprova que ONVIF est\u00e0 activat a la configuraci\u00f3 de les c\u00e0meres.", "title": "Configuraci\u00f3 de dispositiu ONVIF" } diff --git a/homeassistant/components/onvif/translations/de.json b/homeassistant/components/onvif/translations/de.json index 109e6256791..fd992c4db05 100644 --- a/homeassistant/components/onvif/translations/de.json +++ b/homeassistant/components/onvif/translations/de.json @@ -3,9 +3,9 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", - "no_h264": "Es waren keine H264-Streams verf\u00fcgbar. \u00dcberpr\u00fcfen Sie die Profilkonfiguration auf Ihrem Ger\u00e4t.", + "no_h264": "Es waren keine H264-Streams verf\u00fcgbar. \u00dcberpr\u00fcfe die Profilkonfiguration auf deinem Ger\u00e4t.", "no_mac": "Die eindeutige ID f\u00fcr das ONVIF-Ger\u00e4t konnte nicht konfiguriert werden.", - "onvif_error": "Fehler beim Einrichten des ONVIF-Ger\u00e4ts. \u00dcberpr\u00fcfen Sie die Protokolle auf weitere Informationen." + "onvif_error": "Fehler beim Einrichten des ONVIF-Ger\u00e4ts. \u00dcberpr\u00fcfe die Protokolle auf weitere Informationen." }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" @@ -16,7 +16,7 @@ "password": "Passwort", "username": "Benutzername" }, - "title": "Konfigurieren Sie die Authentifizierung" + "title": "Konfiguriere die Authentifizierung" }, "configure": { "data": { @@ -26,7 +26,7 @@ "port": "Port", "username": "Benutzername" }, - "title": "Konfigurieren Sie das ONVIF-Ger\u00e4t" + "title": "Konfiguriere das ONVIF-Ger\u00e4t" }, "configure_profile": { "data": { @@ -37,9 +37,9 @@ }, "device": { "data": { - "host": "W\u00e4hlen Sie das erkannte ONVIF-Ger\u00e4t aus" + "host": "W\u00e4hle das erkannte ONVIF-Ger\u00e4t aus" }, - "title": "W\u00e4hlen Sie ONVIF-Ger\u00e4t" + "title": "W\u00e4hle ein ONVIF-Ger\u00e4t" }, "manual_input": { "data": { @@ -47,13 +47,13 @@ "name": "Name", "port": "Port" }, - "title": "Konfigurieren Sie das ONVIF-Ger\u00e4t" + "title": "Konfiguriere das ONVIF-Ger\u00e4t" }, "user": { "data": { "auto": "Automatisch suchen" }, - "description": "Wenn Sie auf Senden klicken, durchsuchen wir Ihr Netzwerk nach ONVIF-Ger\u00e4ten, die Profil S unterst\u00fctzen. \n\nEinige Hersteller haben begonnen, ONVIF standardm\u00e4\u00dfig zu deaktivieren. Stellen Sie sicher, dass ONVIF in der Konfiguration Ihrer Kamera aktiviert ist.", + "description": "Wenn du auf Senden klickst, durchsuchen wir dein Netzwerk nach ONVIF-Ger\u00e4ten, die Profil S unterst\u00fctzen. \n\nEinige Hersteller haben begonnen, ONVIF standardm\u00e4\u00dfig zu deaktivieren. Stelle sicher, dass ONVIF in der Konfiguration deiner Kamera aktiviert ist.", "title": "ONVIF-Ger\u00e4tekonfiguration" } } diff --git a/homeassistant/components/onvif/translations/es.json b/homeassistant/components/onvif/translations/es.json index 5b52990dde5..d5c9688b875 100644 --- a/homeassistant/components/onvif/translations/es.json +++ b/homeassistant/components/onvif/translations/es.json @@ -18,6 +18,16 @@ }, "title": "Configurar la autenticaci\u00f3n" }, + "configure": { + "data": { + "host": "Host", + "name": "Nombre", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, + "title": "Configurar dispositivo ONVIF" + }, "configure_profile": { "data": { "include": "Crear la entidad de la c\u00e1mara" @@ -40,6 +50,9 @@ "title": "Configurar el dispositivo ONVIF" }, "user": { + "data": { + "auto": "Buscar autom\u00e1ticamente" + }, "description": "Al hacer clic en Enviar, buscaremos en su red dispositivos ONVIF compatibles con el perfil S.\n\nAlgunos fabricantes han comenzado a desactivar ONVIF de forma predeterminada. Aseg\u00farese de que ONVIF est\u00e9 activado en la configuraci\u00f3n de la c\u00e1mara.", "title": "Configuraci\u00f3n del dispositivo ONVIF" } diff --git a/homeassistant/components/onvif/translations/fr.json b/homeassistant/components/onvif/translations/fr.json index d8d1cf89611..76eb733db3d 100644 --- a/homeassistant/components/onvif/translations/fr.json +++ b/homeassistant/components/onvif/translations/fr.json @@ -18,6 +18,16 @@ }, "title": "Configurer l'authentification" }, + "configure": { + "data": { + "host": "H\u00f4te", + "name": "Nom", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + }, + "title": "Configurer le p\u00e9riph\u00e9rique ONVIF" + }, "configure_profile": { "data": { "include": "Cr\u00e9er une entit\u00e9 cam\u00e9ra" @@ -40,6 +50,9 @@ "title": "Configurer l\u2019appareil ONVIF" }, "user": { + "data": { + "auto": "Rechercher automatiquement" + }, "description": "En cliquant sur soumettre, nous rechercherons sur votre r\u00e9seau, des \u00e9quipements ONVIF qui supporte le Profile S.\n\nCertains constructeurs ont commenc\u00e9 \u00e0 d\u00e9sactiver ONvif par d\u00e9faut. Assurez-vous qu\u2019ONVIF est activ\u00e9 dans la configuration de votre cam\u00e9ra", "title": "Configuration de l'appareil ONVIF" } diff --git a/homeassistant/components/onvif/translations/he.json b/homeassistant/components/onvif/translations/he.json index ec9da5b556e..6d1229f1a5d 100644 --- a/homeassistant/components/onvif/translations/he.json +++ b/homeassistant/components/onvif/translations/he.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", - "no_h264": "\u05dc\u05d0 \u05d4\u05d9\u05d5 \u05d6\u05e8\u05de\u05d9 H264 \u05d6\u05de\u05d9\u05e0\u05d9\u05dd. \u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc \u05d1\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05dc\u05da.", + "no_h264": "\u05dc\u05d0 \u05d4\u05d9\u05d5 \u05d6\u05e8\u05de\u05d9 H264 \u05d6\u05de\u05d9\u05e0\u05d9\u05dd. \u05d9\u05e9 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e4\u05e8\u05d5\u05e4\u05d9\u05dc \u05d1\u05d4\u05ea\u05e7\u05df \u05e9\u05dc\u05da.", "no_mac": "\u05dc\u05d0 \u05d4\u05d9\u05ea\u05d4 \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05e7\u05d1\u05d5\u05e2 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05de\u05d6\u05d4\u05d4 \u05d9\u05d9\u05d7\u05d5\u05d3\u05d9 \u05e2\u05d1\u05d5\u05e8 \u05d4\u05ea\u05e7\u05df ONVIF." }, "error": { @@ -16,6 +16,16 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" } }, + "configure": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + }, + "title": "\u05e7\u05d1\u05d9\u05e2\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e9\u05dc \u05d4\u05ea\u05e7\u05df ONVIF" + }, "configure_profile": { "data": { "include": "\u05e6\u05d5\u05e8 \u05d9\u05e9\u05d5\u05ea \u05de\u05e6\u05dc\u05de\u05d4" diff --git a/homeassistant/components/onvif/translations/hu.json b/homeassistant/components/onvif/translations/hu.json index 61a6cfb056e..e2b63a6c9d8 100644 --- a/homeassistant/components/onvif/translations/hu.json +++ b/homeassistant/components/onvif/translations/hu.json @@ -18,6 +18,16 @@ }, "title": "Hiteles\u00edt\u00e9s konfigur\u00e1l\u00e1sa" }, + "configure": { + "data": { + "host": "Gazdag\u00e9p", + "name": "N\u00e9v", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + }, + "title": "Konfigur\u00e1lja az ONVIF eszk\u00f6zt" + }, "configure_profile": { "data": { "include": "Hozzon l\u00e9tre kamera entit\u00e1st" @@ -40,6 +50,9 @@ "title": "ONVIF eszk\u00f6z konfigur\u00e1l\u00e1sa" }, "user": { + "data": { + "auto": "Automatikus keres\u00e9s" + }, "description": "A k\u00fcld\u00e9s gombra kattintva olyan ONVIF-eszk\u00f6z\u00f6ket keres\u00fcnk a h\u00e1l\u00f3zat\u00e1ban, amelyek t\u00e1mogatj\u00e1k az S profilt.\n\nEgyes gy\u00e1rt\u00f3k alap\u00e9rtelmez\u00e9s szerint elkezdt\u00e9k letiltani az ONVIF-et. Ellen\u0151rizze, hogy az ONVIF enged\u00e9lyezve van-e a kamera konfigur\u00e1ci\u00f3j\u00e1ban." } } diff --git a/homeassistant/components/onvif/translations/it.json b/homeassistant/components/onvif/translations/it.json index 118af410974..eea178f990a 100644 --- a/homeassistant/components/onvif/translations/it.json +++ b/homeassistant/components/onvif/translations/it.json @@ -18,6 +18,16 @@ }, "title": "Configurare l'autenticazione" }, + "configure": { + "data": { + "host": "Host", + "name": "Nome", + "password": "Password", + "port": "Porta", + "username": "Nome utente" + }, + "title": "Configura il dispositivo ONVIF" + }, "configure_profile": { "data": { "include": "Crea entit\u00e0 telecamera" @@ -40,6 +50,9 @@ "title": "Configurare il dispositivo ONVIF" }, "user": { + "data": { + "auto": "Cerca automaticamente" + }, "description": "Facendo clic su Invia, cercheremo nella tua rete i dispositivi ONVIF che supportano il profilo S. \n\nAlcuni produttori hanno iniziato a disabilitare ONVIF per impostazione predefinita. Assicurati che ONVIF sia abilitato nella configurazione della tua telecamera.", "title": "Configurazione del dispositivo ONVIF" } diff --git a/homeassistant/components/onvif/translations/pl.json b/homeassistant/components/onvif/translations/pl.json index 2d2f55728ad..0b72b824abf 100644 --- a/homeassistant/components/onvif/translations/pl.json +++ b/homeassistant/components/onvif/translations/pl.json @@ -18,6 +18,16 @@ }, "title": "Konfiguracja uwierzytelniania" }, + "configure": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + }, + "title": "Konfiguracja urz\u0105dzenia ONVIF" + }, "configure_profile": { "data": { "include": "Utw\u00f3rz encj\u0119 kamery" @@ -40,6 +50,9 @@ "title": "Konfiguracja urz\u0105dzenia ONVIF" }, "user": { + "data": { + "auto": "Wyszukaj automatycznie" + }, "description": "Klikaj\u0105c przycisk Zatwierd\u017a, Twoja sie\u0107 zostanie przeszukana pod k\u0105tem urz\u0105dze\u0144 ONVIF obs\u0142uguj\u0105cych profil S.\n\nNiekt\u00f3rzy producenci zacz\u0119li domy\u015blnie wy\u0142\u0105cza\u0107 ONVIF. Upewnij si\u0119, \u017ce ONVIF jest w\u0142\u0105czony w konfiguracji kamery.", "title": "Konfiguracja urz\u0105dzenia ONVIF" } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index d011c485d42..b7b8024009b 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.20.3", "opencv-python-headless==4.4.0.42"], + "requirements": ["numpy==1.21.1", "opencv-python-headless==4.5.2.54"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index d7d4149e26d..29eeceb232c 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_HOST, CONF_MONITORED_VARIABLES, + DEVICE_CLASS_TEMPERATURE, ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS, TIME_MINUTES, @@ -18,13 +19,13 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { - "status": ["Charging Status", None], - "charge_time": ["Charge Time Elapsed", TIME_MINUTES], - "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS], - "ir_temp": ["IR Temperature", TEMP_CELSIUS], - "rtc_temp": ["RTC Temperature", TEMP_CELSIUS], - "usage_session": ["Usage this Session", ENERGY_KILO_WATT_HOUR], - "usage_total": ["Total Usage", ENERGY_KILO_WATT_HOUR], + "status": ["Charging Status", None, None], + "charge_time": ["Charge Time Elapsed", TIME_MINUTES, None], + "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], + "ir_temp": ["IR Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], + "rtc_temp": ["RTC Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], + "usage_session": ["Usage this Session", ENERGY_KILO_WATT_HOUR, None], + "usage_total": ["Total Usage", ENERGY_KILO_WATT_HOUR, None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -61,6 +62,7 @@ class OpenEVSESensor(SensorEntity): self._state = None self.charger = charger self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._attr_device_class = SENSOR_TYPES[sensor_type][2] @property def name(self): diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json index a14fb232eac..b6c617408b5 100644 --- a/homeassistant/components/opengarage/manifest.json +++ b/homeassistant/components/opengarage/manifest.json @@ -3,6 +3,6 @@ "name": "OpenGarage", "documentation": "https://www.home-assistant.io/integrations/opengarage", "codeowners": ["@danielhiversen"], - "requirements": ["open-garage==0.1.4"], + "requirements": ["open-garage==0.1.5"], "iot_class": "local_polling" } diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 70d0d36176c..8f43c1e5e9b 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -88,8 +88,7 @@ class OpenHardwareMonitorDevice(SensorEntity): array = self._data.data[OHM_CHILDREN] _attributes = {} - for path_index in range(0, len(self.path)): - path_number = self.path[path_index] + for path_index, path_number in enumerate(self.path): values = array[path_number] if path_index == len(self.path) - 1: @@ -109,7 +108,7 @@ class OpenHardwareMonitorDevice(SensorEntity): self.attributes = _attributes return array = array[path_number][OHM_CHILDREN] - _attributes.update({"level_%s" % path_index: values[OHM_NAME]}) + _attributes.update({f"level_{path_index}": values[OHM_NAME]}) class OpenHardwareMonitorData: diff --git a/homeassistant/components/opentherm_gw/translations/hu.json b/homeassistant/components/opentherm_gw/translations/hu.json index 78ff8c88636..77112bd8929 100644 --- a/homeassistant/components/opentherm_gw/translations/hu.json +++ b/homeassistant/components/opentherm_gw/translations/hu.json @@ -21,6 +21,8 @@ "init": { "data": { "floor_temperature": "Padl\u00f3 h\u0151m\u00e9rs\u00e9klete", + "read_precision": "Pontoss\u00e1g olvas\u00e1sa", + "set_precision": "Pontoss\u00e1g be\u00e1ll\u00edt\u00e1sa", "temporary_override_mode": "Ideiglenes be\u00e1ll\u00edt\u00e1s fel\u00fclb\u00edr\u00e1l\u00e1si m\u00f3dja" } } diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index e1af166a3c2..efe6fa89ca8 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -1,9 +1,13 @@ """Support for UV data from openuv.io.""" +from __future__ import annotations + import asyncio +from typing import Any from pyopenuv import Client from pyopenuv.errors import OpenUvError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, @@ -13,7 +17,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_SENSORS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import ( @@ -42,14 +46,10 @@ TOPIC_UPDATE = f"{DOMAIN}_data_update" PLATFORMS = ["binary_sensor", "sensor"] -async def async_setup(hass, config): - """Set up the OpenUV component.""" - hass.data[DOMAIN] = {DATA_CLIENT: {}, DATA_LISTENER: {}} - return True - - -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up OpenUV as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_CLIENT: {}, DATA_LISTENER: {}}) + _verify_domain_control = verify_domain_control(hass, DOMAIN) try: @@ -72,37 +72,37 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @_verify_domain_control - async def update_data(service): + async def update_data(_: ServiceCall) -> None: """Refresh all OpenUV data.""" LOGGER.debug("Refreshing all OpenUV data") await openuv.async_update() async_dispatcher_send(hass, TOPIC_UPDATE) @_verify_domain_control - async def update_uv_index_data(service): + async def update_uv_index_data(_: ServiceCall) -> None: """Refresh OpenUV UV index data.""" LOGGER.debug("Refreshing OpenUV UV index data") await openuv.async_update_uv_index_data() async_dispatcher_send(hass, TOPIC_UPDATE) @_verify_domain_control - async def update_protection_data(service): + async def update_protection_data(_: ServiceCall) -> None: """Refresh OpenUV protection window data.""" LOGGER.debug("Refreshing OpenUV protection window data") await openuv.async_update_protection_data() async_dispatcher_send(hass, TOPIC_UPDATE) - for service, method in [ + for service, method in ( ("update_data", update_data), ("update_uv_index_data", update_uv_index_data), ("update_protection_data", update_protection_data), - ]: + ): hass.services.async_register(DOMAIN, service, method) return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload an OpenUV config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -113,7 +113,7 @@ async def async_unload_entry(hass, config_entry): return unload_ok -async def async_migrate_entry(hass, config_entry): +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate the config entry upon new versions.""" version = config_entry.version data = {**config_entry.data} @@ -134,12 +134,12 @@ async def async_migrate_entry(hass, config_entry): class OpenUV: """Define a generic OpenUV object.""" - def __init__(self, client): + def __init__(self, client: Client) -> None: """Initialize.""" self.client = client - self.data = {} + self.data: dict[str, Any] = {} - async def async_update_protection_data(self): + async def async_update_protection_data(self) -> None: """Update binary sensor (protection window) data.""" try: resp = await self.client.uv_protection_window() @@ -148,7 +148,7 @@ class OpenUV: LOGGER.error("Error during protection data update: %s", err) self.data[DATA_PROTECTION_WINDOW] = {} - async def async_update_uv_index_data(self): + async def async_update_uv_index_data(self) -> None: """Update sensor (uv index, etc) data.""" try: data = await self.client.uv_index() @@ -157,7 +157,7 @@ class OpenUV: LOGGER.error("Error during uv index data update: %s", err) self.data[DATA_UV] = {} - async def async_update(self): + async def async_update(self) -> None: """Update sensor/binary sensor data.""" tasks = [self.async_update_protection_data(), self.async_update_uv_index_data()] await asyncio.gather(*tasks) @@ -166,33 +166,21 @@ class OpenUV: class OpenUvEntity(Entity): """Define a generic OpenUV entity.""" - def __init__(self, openuv): + def __init__(self, openuv: OpenUV, sensor_type: str) -> None: """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._available = True - self._name = None + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_should_poll = False + self._attr_unique_id = ( + f"{openuv.client.latitude}_{openuv.client.longitude}_{sensor_type}" + ) + self._sensor_type = sensor_type self.openuv = openuv - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" @callback - def update(): + def update() -> None: """Update the state.""" self.update_from_latest_data() self.async_write_ha_state() @@ -201,6 +189,6 @@ class OpenUvEntity(Entity): self.update_from_latest_data() - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the sensor using the latest data.""" raise NotImplementedError diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 62a83cdb141..12b1f0c82af 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -1,9 +1,11 @@ """Support for OpenUV binary sensors.""" from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime, utcnow -from . import OpenUvEntity +from . import OpenUV, OpenUvEntity from .const import ( DATA_CLIENT, DATA_PROTECTION_WINDOW, @@ -20,16 +22,16 @@ ATTR_PROTECTION_WINDOW_STARTING_UV = "start_uv" BINARY_SENSORS = {TYPE_PROTECTION_WINDOW: ("Protection Window", "mdi:sunglasses")} -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up an OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] binary_sensors = [] for kind, attrs in BINARY_SENSORS.items(): name, icon = attrs - binary_sensors.append( - OpenUvBinarySensor(openuv, kind, name, icon, entry.entry_id) - ) + binary_sensors.append(OpenUvBinarySensor(openuv, kind, name, icon)) async_add_entities(binary_sensors, True) @@ -37,49 +39,23 @@ async def async_setup_entry(hass, entry, async_add_entities): class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" - def __init__(self, openuv, sensor_type, name, icon, entry_id): + def __init__(self, openuv: OpenUV, sensor_type: str, name: str, icon: str) -> None: """Initialize the sensor.""" - super().__init__(openuv) + super().__init__(openuv, sensor_type) - self._async_unsub_dispatcher_connect = None - self._entry_id = entry_id - self._icon = icon - self._latitude = openuv.client.latitude - self._longitude = openuv.client.longitude - self._name = name - self._sensor_type = sensor_type - self._state = None - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._latitude}_{self._longitude}_{self._sensor_type}" + self._attr_icon = icon + self._attr_name = name @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the state.""" data = self.openuv.data[DATA_PROTECTION_WINDOW] if not data: - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True for key in ("from_time", "to_time", "from_uv", "to_uv"): if not data.get(key): @@ -87,20 +63,24 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): return if self._sensor_type == TYPE_PROTECTION_WINDOW: - self._state = ( - parse_datetime(data["from_time"]) - <= utcnow() - <= parse_datetime(data["to_time"]) - ) - self._attrs.update( + from_dt = parse_datetime(data["from_time"]) + to_dt = parse_datetime(data["to_time"]) + + if not from_dt or not to_dt: + LOGGER.warning( + "Unable to parse protection window datetimes: %s, %s", + data["from_time"], + data["to_time"], + ) + self._attr_is_on = False + return + + self._attr_is_on = from_dt <= utcnow() <= to_dt + self._attr_extra_state_attributes.update( { - ATTR_PROTECTION_WINDOW_ENDING_TIME: as_local( - parse_datetime(data["to_time"]) - ), + ATTR_PROTECTION_WINDOW_ENDING_TIME: as_local(to_dt), ATTR_PROTECTION_WINDOW_ENDING_UV: data["to_uv"], ATTR_PROTECTION_WINDOW_STARTING_UV: data["from_uv"], - ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local( - parse_datetime(data["from_time"]) - ), + ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), } ) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index e31cef9ee0a..54b2aca0b75 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -1,4 +1,8 @@ """Config flow to configure the OpenUV component.""" +from __future__ import annotations + +from typing import Any + from pyopenuv import Client from pyopenuv.errors import OpenUvError import voluptuous as vol @@ -10,6 +14,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, ) +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN @@ -21,7 +26,7 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 2 @property - def config_schema(self): + def config_schema(self) -> vol.Schema: """Return the config schema.""" return vol.Schema( { @@ -38,7 +43,7 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, Any] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", @@ -46,11 +51,13 @@ class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) - async def async_step_import(self, import_config): + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return await self._show_form() diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 654a89cfcf9..386527ebc3e 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,10 +1,14 @@ """Support for OpenUV sensors.""" +from __future__ import annotations + from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import TIME_MINUTES, UV_INDEX -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime -from . import OpenUvEntity +from . import OpenUV, OpenUvEntity from .const import ( DATA_CLIENT, DATA_UV, @@ -76,14 +80,16 @@ SENSORS = { } -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up a OpenUV sensor based on a config entry.""" openuv = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] sensors = [] for kind, attrs in SENSORS.items(): name, icon, unit = attrs - sensors.append(OpenUvSensor(openuv, kind, name, icon, unit, entry.entry_id)) + sensors.append(OpenUvSensor(openuv, kind, name, icon, unit)) async_add_entities(sensors, True) @@ -91,76 +97,49 @@ async def async_setup_entry(hass, entry, async_add_entities): class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - def __init__(self, openuv, sensor_type, name, icon, unit, entry_id): + def __init__( + self, openuv: OpenUV, sensor_type: str, name: str, icon: str, unit: str | None + ) -> None: """Initialize the sensor.""" - super().__init__(openuv) + super().__init__(openuv, sensor_type) - self._async_unsub_dispatcher_connect = None - self._entry_id = entry_id - self._icon = icon - self._latitude = openuv.client.latitude - self._longitude = openuv.client.longitude - self._name = name - self._sensor_type = sensor_type - self._state = None - self._unit = unit - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def state(self): - """Return the status of the sensor.""" - return self._state - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._latitude}_{self._longitude}_{self._sensor_type}" - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit + self._attr_icon = icon + self._attr_name = name + self._attr_unit_of_measurement = unit @callback - def update_from_latest_data(self): + def update_from_latest_data(self) -> None: """Update the state.""" data = self.openuv.data[DATA_UV].get("result") if not data: - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: - self._state = data["ozone"] + self._attr_state = data["ozone"] elif self._sensor_type == TYPE_CURRENT_UV_INDEX: - self._state = data["uv"] + self._attr_state = data["uv"] elif self._sensor_type == TYPE_CURRENT_UV_LEVEL: if data["uv"] >= 11: - self._state = UV_LEVEL_EXTREME + self._attr_state = UV_LEVEL_EXTREME elif data["uv"] >= 8: - self._state = UV_LEVEL_VHIGH + self._attr_state = UV_LEVEL_VHIGH elif data["uv"] >= 6: - self._state = UV_LEVEL_HIGH + self._attr_state = UV_LEVEL_HIGH elif data["uv"] >= 3: - self._state = UV_LEVEL_MODERATE + self._attr_state = UV_LEVEL_MODERATE else: - self._state = UV_LEVEL_LOW + self._attr_state = UV_LEVEL_LOW elif self._sensor_type == TYPE_MAX_UV_INDEX: - self._state = data["uv_max"] - self._attrs.update( - {ATTR_MAX_UV_TIME: as_local(parse_datetime(data["uv_max_time"]))} - ) + self._attr_state = data["uv_max"] + uv_max_time = parse_datetime(data["uv_max_time"]) + if uv_max_time: + self._attr_extra_state_attributes.update( + {ATTR_MAX_UV_TIME: as_local(uv_max_time)} + ) elif self._sensor_type in ( TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2, @@ -169,6 +148,6 @@ class OpenUvSensor(OpenUvEntity, SensorEntity): TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ): - self._state = data["safe_exposure_time"][ + self._attr_state = data["safe_exposure_time"][ EXPOSURE_TYPE_MAP[self._sensor_type] ] diff --git a/homeassistant/components/openweathermap/translations/ar.json b/homeassistant/components/openweathermap/translations/ar.json new file mode 100644 index 00000000000..1f69a1cf739 --- /dev/null +++ b/homeassistant/components/openweathermap/translations/ar.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "data": { + "language": "\u0627\u0644\u0644\u063a\u0629", + "name": "\u0627\u0633\u0645 \u0627\u0644\u062a\u0643\u0627\u0645\u0644" + }, + "description": "\u0642\u0645 \u0628\u0625\u0639\u062f\u0627\u062f \u062a\u0643\u0627\u0645\u0644 OpenWeatherMap. \u0644\u0625\u0646\u0634\u0627\u0621 \u0645\u0641\u062a\u0627\u062d API \u060c \u0627\u0646\u062a\u0642\u0644 \u0625\u0644\u0649 https://openweathermap.org/appid", + "title": "OpenWeatherMap" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "language": "\u0627\u0644\u0644\u063a\u0629" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/openweathermap/translations/de.json b/homeassistant/components/openweathermap/translations/de.json index da0305f633d..615a642a859 100644 --- a/homeassistant/components/openweathermap/translations/de.json +++ b/homeassistant/components/openweathermap/translations/de.json @@ -10,14 +10,14 @@ "step": { "user": { "data": { - "api_key": "API Key", + "api_key": "API-Schl\u00fcssel", "language": "Sprache", "latitude": "Breitengrad", "longitude": "L\u00e4ngengrad", "mode": "Modus", "name": "Name der Integration" }, - "description": "Richten Sie die OpenWeatherMap-Integration ein. Zum Generieren des API-Schl\u00fcssels gehen Sie auf https://openweathermap.org/appid", + "description": "Richte die OpenWeatherMap-Integration ein. Zum Generieren des API-Schl\u00fcssels gehe auf https://openweathermap.org/appid", "title": "OpenWeatherMap" } } diff --git a/homeassistant/components/openweathermap/translations/he.json b/homeassistant/components/openweathermap/translations/he.json index 554fefee321..ca5e388ea98 100644 --- a/homeassistant/components/openweathermap/translations/he.json +++ b/homeassistant/components/openweathermap/translations/he.json @@ -14,8 +14,10 @@ "language": "\u05e9\u05e4\u05d4", "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1", "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da", - "mode": "\u05de\u05e6\u05d1" - } + "mode": "\u05de\u05e6\u05d1", + "name": "\u05e9\u05dd \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1" + }, + "title": "\u05de\u05e4\u05ea OpenWeather" } } }, diff --git a/homeassistant/components/ovo_energy/translations/de.json b/homeassistant/components/ovo_energy/translations/de.json index de86a7adf14..74d3007430f 100644 --- a/homeassistant/components/ovo_energy/translations/de.json +++ b/homeassistant/components/ovo_energy/translations/de.json @@ -11,7 +11,7 @@ "data": { "password": "Passwort" }, - "description": "Die Authentifizierung f\u00fcr OVO Energy ist fehlgeschlagen. Bitte geben Sie Ihre aktuellen Anmeldedaten ein.", + "description": "Die Authentifizierung f\u00fcr OVO Energy ist fehlgeschlagen. Bitte gib deine aktuellen Anmeldedaten ein.", "title": "Erneute Authentifizierung" }, "user": { diff --git a/homeassistant/components/ovo_energy/translations/he.json b/homeassistant/components/ovo_energy/translations/he.json index 7864218bc3b..270d8744b96 100644 --- a/homeassistant/components/ovo_energy/translations/he.json +++ b/homeassistant/components/ovo_energy/translations/he.json @@ -17,7 +17,8 @@ "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, - "description": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d5\u05e4\u05e2 OVO \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d2\u05e9\u05ea \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc\u05da." + "description": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d5\u05e4\u05e2 OVO \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d2\u05e9\u05ea \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc\u05da.", + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05d0\u05e0\u05e8\u05d2\u05d9\u05d4 \u05e9\u05dc OVO" } } } diff --git a/homeassistant/components/ovo_energy/translations/hu.json b/homeassistant/components/ovo_energy/translations/hu.json index 14b3b23b2e7..143d1a8dc18 100644 --- a/homeassistant/components/ovo_energy/translations/hu.json +++ b/homeassistant/components/ovo_energy/translations/hu.json @@ -11,6 +11,7 @@ "data": { "password": "Jelsz\u00f3" }, + "description": "Nem siker\u00fclt az OVO Energy hiteles\u00edt\u00e9se. K\u00e9rj\u00fck, adja meg jelenlegi hiteles\u00edt\u0151 adatait.", "title": "\u00dajrahiteles\u00edt\u00e9s" }, "user": { diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json index 9e83e5b4ec4..40dbb7d569c 100644 --- a/homeassistant/components/owntracks/manifest.json +++ b/homeassistant/components/owntracks/manifest.json @@ -3,7 +3,7 @@ "name": "OwnTracks", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/owntracks", - "requirements": ["PyNaCl==1.3.0"], + "requirements": ["PyNaCl==1.4.0"], "dependencies": ["webhook"], "after_dependencies": ["mqtt", "cloud"], "codeowners": [], diff --git a/homeassistant/components/owntracks/translations/de.json b/homeassistant/components/owntracks/translations/de.json index da313efbe6e..891f914f8a9 100644 --- a/homeassistant/components/owntracks/translations/de.json +++ b/homeassistant/components/owntracks/translations/de.json @@ -4,7 +4,7 @@ "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "create_entry": { - "default": "Unter Android \u00f6ffnen Sie [die OwnTracks App]({android_url}), gehen Sie zu Einstellungen -> Verbindung. \u00c4ndern Sie die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `''`\n - Ger\u00e4te-ID: `''`\n\nUnter iOS \u00f6ffnen Sie [die OwnTracks App]({ios_url}), tippen Sie auf das (i)-Symbol oben links -> Einstellungen. \u00c4ndern Sie die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `''`\n\n{secret}\n\nWeitere Informationen finden Sie in [der Dokumentation]({docs_url}).\n\n\u00dcbersetzt mit www.DeepL.com/Translator (kostenlose Version)" + "default": "Unter Android \u00f6ffne [die OwnTracks App]({android_url}), gehe zu Einstellungen -> Verbindung. \u00c4ndere die folgenden Einstellungen:\n - Modus: Privat HTTP\n - Host: {webhook_url}\n - Identifikation:\n - Benutzername: `''`\n - Ger\u00e4te-ID: `''`\n\nUnter iOS \u00f6ffne [die OwnTracks App]({ios_url}), tippe auf das (i)-Symbol oben links -> Einstellungen. \u00c4ndere die folgenden Einstellungen:\n - Modus: HTTP\n - URL: {webhook_url}\n - Authentifizierung einschalten\n - UserID: `''`\n\n{secret}\n\nWeitere Informationen findest du in [der Dokumentation]({docs_url})." }, "step": { "user": { diff --git a/homeassistant/components/ozw/__init__.py b/homeassistant/components/ozw/__init__.py index 6d9b474977d..fc84a8ac7b0 100644 --- a/homeassistant/components/ozw/__init__.py +++ b/homeassistant/components/ozw/__init__.py @@ -264,10 +264,10 @@ async def async_setup_entry( # noqa: C901 async def start_platforms(): await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) if entry.data.get(CONF_USE_ADDON): mqtt_client_task = asyncio.create_task(mqtt_client.start_client(manager)) diff --git a/homeassistant/components/ozw/entity.py b/homeassistant/components/ozw/entity.py index 305601a2333..d5cafa615df 100644 --- a/homeassistant/components/ozw/entity.py +++ b/homeassistant/components/ozw/entity.py @@ -83,9 +83,9 @@ class ZWaveDeviceEntityValues: return # Go through the possible values for this entity defined by the schema. - for name in self._values: + for name, name_value in self._values.items(): # Skip if it's already been added. - if self._values[name] is not None: + if name_value is not None: continue # Skip if the value doesn't match the schema. if not check_value_schema(value, self._schema[const.DISC_VALUES][name]): diff --git a/homeassistant/components/ozw/translations/he.json b/homeassistant/components/ozw/translations/he.json index 10f3cb6d722..021d34db25a 100644 --- a/homeassistant/components/ozw/translations/he.json +++ b/homeassistant/components/ozw/translations/he.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "mqtt_required": "\u05e9\u05d9\u05dc\u05d5\u05d1 MQTT \u05d0\u05d9\u05e0\u05d5 \u05de\u05d5\u05d2\u05d3\u05e8", "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { diff --git a/homeassistant/components/ozw/translations/hu.json b/homeassistant/components/ozw/translations/hu.json index 6c2c6c22f55..70934bf3472 100644 --- a/homeassistant/components/ozw/translations/hu.json +++ b/homeassistant/components/ozw/translations/hu.json @@ -11,7 +11,16 @@ "error": { "addon_start_failed": "Nem siker\u00fclt elind\u00edtani az OpenZWave b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t." }, + "progress": { + "install_addon": "V\u00e1rjon, am\u00edg az OpenZWave kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat." + }, "step": { + "hassio_confirm": { + "title": "\u00c1ll\u00edtsa be az OpenZWave integr\u00e1ci\u00f3t az OpenZWave kieg\u00e9sz\u00edt\u0151vel" + }, + "install_addon": { + "title": "Elindult az OpenZWave kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se" + }, "on_supervisor": { "data": { "use_addon": "Haszn\u00e1ld az OpenZWave Supervisor b\u0151v\u00edtm\u00e9nyt" @@ -23,7 +32,8 @@ "data": { "network_key": "H\u00e1l\u00f3zati kulcs", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" - } + }, + "title": "Adja meg az OpenZWave b\u0151v\u00edtm\u00e9ny konfigur\u00e1ci\u00f3j\u00e1t" } } } diff --git a/homeassistant/components/panasonic_viera/translations/de.json b/homeassistant/components/panasonic_viera/translations/de.json index 71090830714..73143624e68 100644 --- a/homeassistant/components/panasonic_viera/translations/de.json +++ b/homeassistant/components/panasonic_viera/translations/de.json @@ -23,7 +23,7 @@ "name": "Name" }, "description": "Gib die IP-Adresse deines Panasonic Viera TV ein", - "title": "Richten Sie Ihr Fernsehger\u00e4t ein" + "title": "Richte dein Fernsehger\u00e4t ein" } } } diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 071261e7b23..4a68dd3356f 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -80,11 +80,11 @@ def async_create( """Generate a notification.""" data = { key: value - for key, value in [ + for key, value in ( (ATTR_TITLE, title), (ATTR_MESSAGE, message), (ATTR_NOTIFICATION_ID, notification_id), - ] + ) if value is not None } diff --git a/homeassistant/components/person/translations/he.json b/homeassistant/components/person/translations/he.json index 8bc21b19133..1c36d16f936 100644 --- a/homeassistant/components/person/translations/he.json +++ b/homeassistant/components/person/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "home": "\u05d1\u05d1\u05d9\u05ea", - "not_home": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0" + "not_home": "\u05dc\u05d0 \u05d1\u05d1\u05d9\u05ea" } }, "title": "\u05d0\u05d3\u05dd" diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py index 4c321468d79..3dbca7611ab 100644 --- a/homeassistant/components/philips_js/light.py +++ b/homeassistant/components/philips_js/light.py @@ -18,8 +18,7 @@ from homeassistant.components.light import ( SUPPORT_EFFECT, LightEntity, ) -from homeassistant.core import callback -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv @@ -34,7 +33,7 @@ EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"} async def async_setup_entry( - hass: HomeAssistantType, + hass: HomeAssistant, config_entry: config_entries.ConfigEntry, async_add_entities, ): diff --git a/homeassistant/components/philips_js/translations/ca.json b/homeassistant/components/philips_js/translations/ca.json index b94faccd615..1999639623c 100644 --- a/homeassistant/components/philips_js/translations/ca.json +++ b/homeassistant/components/philips_js/translations/ca.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Es demani que el dispositiu s'engegui" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Permet l'\u00fas del servei de notificaci\u00f3 de dades." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/es.json b/homeassistant/components/philips_js/translations/es.json index 5cd00abc216..153512cb83b 100644 --- a/homeassistant/components/philips_js/translations/es.json +++ b/homeassistant/components/philips_js/translations/es.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Se solicita al dispositivo que se encienda" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Permitir el uso del servicio de notificaci\u00f3n de datos." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/fr.json b/homeassistant/components/philips_js/translations/fr.json index eb16bb92271..8cc5d187743 100644 --- a/homeassistant/components/philips_js/translations/fr.json +++ b/homeassistant/components/philips_js/translations/fr.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Il a \u00e9t\u00e9 demand\u00e9 \u00e0 l'appareil de s'allumer" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Autoriser l'utilisation du service de notification de donn\u00e9es." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/hu.json b/homeassistant/components/philips_js/translations/hu.json index f7ce3f708b0..1fe4811d21e 100644 --- a/homeassistant/components/philips_js/translations/hu.json +++ b/homeassistant/components/philips_js/translations/hu.json @@ -24,5 +24,19 @@ } } } + }, + "device_automation": { + "trigger_type": { + "turn_on": "Az eszk\u00f6znek be kell lennie kapcsolva" + } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Adat\u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s haszn\u00e1lat\u00e1nak enged\u00e9lyez\u00e9se." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/it.json b/homeassistant/components/philips_js/translations/it.json index 6ff668dbea8..1e08d052c63 100644 --- a/homeassistant/components/philips_js/translations/it.json +++ b/homeassistant/components/philips_js/translations/it.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Si richiede l'accensione del dispositivo" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Consenti l'utilizzo del servizio di notifica dei dati." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/philips_js/translations/pl.json b/homeassistant/components/philips_js/translations/pl.json index a89b6136ff8..fce4ac34c83 100644 --- a/homeassistant/components/philips_js/translations/pl.json +++ b/homeassistant/components/philips_js/translations/pl.json @@ -29,5 +29,14 @@ "trigger_type": { "turn_on": "Urz\u0105dzenie zostanie poproszone o w\u0142\u0105czenie" } + }, + "options": { + "step": { + "init": { + "data": { + "allow_notify": "Zezw\u00f3l na korzystanie z us\u0142ugi powiadamiania o danych." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pi_hole/translations/de.json b/homeassistant/components/pi_hole/translations/de.json index 6d9518490d5..40a5db3c21f 100644 --- a/homeassistant/components/pi_hole/translations/de.json +++ b/homeassistant/components/pi_hole/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen" @@ -16,10 +16,10 @@ "data": { "api_key": "API-Schl\u00fcssel", "host": "Host", - "location": "Org", + "location": "Standort", "name": "Name", "port": "Port", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "statistics_only": "Nur Statistiken", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" } diff --git a/homeassistant/components/picnic/strings.json b/homeassistant/components/picnic/strings.json index d43a91fbb0c..7fbd5e9bef6 100644 --- a/homeassistant/components/picnic/strings.json +++ b/homeassistant/components/picnic/strings.json @@ -1,5 +1,4 @@ { - "title": "Picnic", "config": { "step": { "user": { @@ -19,4 +18,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/picnic/translations/fr.json b/homeassistant/components/picnic/translations/fr.json index 044b0a72771..75e35a951de 100644 --- a/homeassistant/components/picnic/translations/fr.json +++ b/homeassistant/components/picnic/translations/fr.json @@ -3,6 +3,11 @@ "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/picnic/translations/hu.json b/homeassistant/components/picnic/translations/hu.json new file mode 100644 index 00000000000..c70dcca0260 --- /dev/null +++ b/homeassistant/components/picnic/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "country_code": "Orsz\u00e1g k\u00f3d", + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + }, + "title": "Piknik" +} \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/id.json b/homeassistant/components/picnic/translations/id.json new file mode 100644 index 00000000000..0455a5b3b5e --- /dev/null +++ b/homeassistant/components/picnic/translations/id.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "country_code": "Kode Negara", + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + }, + "title": "Picnic" +} \ No newline at end of file diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index cf2d8f7ed7a..0c82c9ff8c4 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -256,14 +256,18 @@ class PingDataSubProcess(PingData): ) if sys.platform == "win32": - match = WIN32_PING_MATCHER.search(str(out_data).split("\n")[-1]) + match = WIN32_PING_MATCHER.search( + str(out_data).rsplit("\n", maxsplit=1)[-1] + ) rtt_min, rtt_avg, rtt_max = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} if "max/" not in str(out_data): - match = PING_MATCHER_BUSYBOX.search(str(out_data).split("\n")[-1]) + match = PING_MATCHER_BUSYBOX.search( + str(out_data).rsplit("\n", maxsplit=1)[-1] + ) rtt_min, rtt_avg, rtt_max = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} - match = PING_MATCHER.search(str(out_data).split("\n")[-1]) + match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} except asyncio.TimeoutError: diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index b5acecf9314..9fc149b5a1f 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -102,14 +102,14 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Update all the hosts on every interval time.""" results = await gather_with_concurrency( CONCURRENT_PING_LIMIT, - *[hass.async_add_executor_job(host.update) for host in hosts], + *(hass.async_add_executor_job(host.update) for host in hosts), ) await asyncio.gather( - *[ + *( async_see(dev_id=host.dev_id, source_type=SOURCE_TYPE_ROUTER) for idx, host in enumerate(hosts) if results[idx] - ] + ) ) else: @@ -124,11 +124,11 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): ) _LOGGER.debug("Multiping responses: %s", responses) await asyncio.gather( - *[ + *( async_see(dev_id=dev_id, source_type=SOURCE_TYPE_ROUTER) for idx, dev_id in enumerate(ip_to_dev_id.values()) if responses[idx].is_alive - ] + ) ) async def _async_update_interval(now): diff --git a/homeassistant/components/plaato/translations/de.json b/homeassistant/components/plaato/translations/de.json index 8d13e5d8cb0..29e3ebe9790 100644 --- a/homeassistant/components/plaato/translations/de.json +++ b/homeassistant/components/plaato/translations/de.json @@ -6,7 +6,7 @@ "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, "create_entry": { - "default": "Ihr Plaato {device_type} mit dem Namen **{device_name}** wurde erfolgreich eingerichtet!" + "default": "Dein Plaato {device_type} mit dem Namen **{device_name}** wurde erfolgreich eingerichtet!" }, "error": { "invalid_webhook_device": "Du hast ein Ger\u00e4t gew\u00e4hlt, das das Senden von Daten an einen Webhook nicht unterst\u00fctzt. Es ist nur f\u00fcr die Airlock verf\u00fcgbar", @@ -16,10 +16,10 @@ "step": { "api_method": { "data": { - "token": "F\u00fcgen Sie hier das Auth Token ein", + "token": "F\u00fcge hier das Auth Token ein", "use_webhook": "Webhook verwenden" }, - "description": "Um die API abfragen zu k\u00f6nnen, wird ein `auth_token` ben\u00f6tigt, das durch folgende [diese](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) Anweisungen erhalten werden kann\n\n Ausgew\u00e4hltes Ger\u00e4t: **{device_type}** \n\nWenn Sie lieber die eingebaute Webhook-Methode (nur Airlock) verwenden m\u00f6chten, setzen Sie bitte einen Haken und lassen Sie das Auth Token leer", + "description": "Um die API abfragen zu k\u00f6nnen, wird ein `auth_token` ben\u00f6tigt, das durch folgende [diese](https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) Anweisungen erhalten werden kann\n\n Ausgew\u00e4hltes Ger\u00e4t: **{device_type}** \n\nWenn du lieber die eingebaute Webhook-Methode (nur Airlock) verwenden m\u00f6chtest, setze bitte einen Haken und lasse das Auth Token leer", "title": "API-Methode ausw\u00e4hlen" }, "user": { @@ -27,7 +27,7 @@ "device_name": "Benenne dein Ger\u00e4t", "device_type": "Art des Plaato-Ger\u00e4ts" }, - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", "title": "Plaato Ger\u00e4te einrichten" }, "webhook": { diff --git a/homeassistant/components/plaato/translations/hu.json b/homeassistant/components/plaato/translations/hu.json index 8347b5d2f98..4778b41e8be 100644 --- a/homeassistant/components/plaato/translations/hu.json +++ b/homeassistant/components/plaato/translations/hu.json @@ -8,7 +8,20 @@ "create_entry": { "default": "A Plaato {device_type} **{device_name}** n\u00e9vvel sikeresen telep\u00edtve lett!" }, + "error": { + "invalid_webhook_device": "Olyan eszk\u00f6zt v\u00e1lasztott, amely nem t\u00e1mogatja az adatok webhookra t\u00f6rt\u00e9n\u0151 k\u00fcld\u00e9s\u00e9t. Csak az Airlock sz\u00e1m\u00e1ra \u00e9rhet\u0151 el", + "no_api_method": "Hozz\u00e1 kell adnia egy hiteles\u00edt\u00e9si tokent, vagy ki kell v\u00e1lasztania a webhookot", + "no_auth_token": "Hozz\u00e1 kell adnia egy hiteles\u00edt\u00e9si tokent" + }, "step": { + "api_method": { + "data": { + "token": "Auth Token beilleszt\u00e9se ide", + "use_webhook": "Webhook haszn\u00e1lata" + }, + "description": "Az API lek\u00e9rdez\u00e9s\u00e9hez egy `auth_token` sz\u00fcks\u00e9ges, amelyet az [ezek] (https://plaato.zendesk.com/hc/en-us/articles/360003234717-Auth-token) utas\u00edt\u00e1sok k\u00f6vet\u00e9s\u00e9vel lehet megszerezni. \n\n Kiv\u00e1lasztott eszk\u00f6z: ** {device_type} ** \n\nHa ink\u00e1bb a be\u00e9p\u00edtett webhook m\u00f3dszert haszn\u00e1lja (csak az Airlock eset\u00e9ben), k\u00e9rj\u00fck, jel\u00f6lje be az al\u00e1bbi n\u00e9gyzetet, \u00e9s hagyja \u00fcresen az Auth Token elemet", + "title": "API m\u00f3dszer kiv\u00e1laszt\u00e1sa" + }, "user": { "data": { "device_name": "Eszk\u00f6z neve", @@ -16,6 +29,25 @@ }, "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?", "title": "A Plaato eszk\u00f6z\u00f6k be\u00e1ll\u00edt\u00e1sa" + }, + "webhook": { + "description": "Ha esem\u00e9nyeket szeretne k\u00fcldeni a Home Assistant alkalmaz\u00e1snak, be kell \u00e1ll\u00edtania a webhook funkci\u00f3t a Plaato Airlock-ban. \n\n T\u00f6ltse ki a k\u00f6vetkez\u0151 inform\u00e1ci\u00f3kat: \n\n - URL: \" {webhook_url} \"\n - M\u00f3dszer: POST \n\n Tov\u00e1bbi r\u00e9szletek\u00e9rt l\u00e1sd a [dokument\u00e1ci\u00f3t] ( {docs_url} ).", + "title": "Haszn\u00e1land\u00f3 webhook" + } + } + }, + "options": { + "step": { + "user": { + "data": { + "update_interval": "Friss\u00edt\u00e9si intervallum (perc)" + }, + "description": "A friss\u00edt\u00e9si id\u0151k\u00f6z be\u00e1ll\u00edt\u00e1sa (percben)", + "title": "Opci\u00f3k a Plaato sz\u00e1m\u00e1ra" + }, + "webhook": { + "description": "Webhook inform\u00e1ci\u00f3k: \n\n - URL: \" {webhook_url} \"\n - M\u00f3dszer: POST \n\n", + "title": "Opci\u00f3k a Plaato Airlock-hoz" } } } diff --git a/homeassistant/components/plant/translations/he.json b/homeassistant/components/plant/translations/he.json index 0263e4da389..a9948ce8765 100644 --- a/homeassistant/components/plant/translations/he.json +++ b/homeassistant/components/plant/translations/he.json @@ -5,5 +5,5 @@ "problem": "\u05d1\u05e2\u05d9\u05d4" } }, - "title": "\u05e6\u05de\u05d7" + "title": "\u05de\u05e0\u05d8\u05e8 \u05e6\u05de\u05d7\u05d9\u05dd" } \ No newline at end of file diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 3de7895a805..40d8ecc675e 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.6.1", + "plexapi==4.7.0", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index f9c40f1edc3..1033c4286ac 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -119,14 +119,18 @@ class PlexMediaPlayer(MediaPlayerEntity): self.machine_identifier = device.machineIdentifier self.session_device = None - self._available = False self._device_protocol_capabilities = None - self._name = None self._previous_volume_level = 1 # Used in fake muting - self._state = STATE_IDLE self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely + self._attr_available = False + self._attr_should_poll = False + self._attr_state = STATE_IDLE + self._attr_unique_id = ( + f"{self.plex_server.machine_identifier}:{self.machine_identifier}" + ) + # Initializes other attributes self.session = session @@ -180,10 +184,10 @@ class PlexMediaPlayer(MediaPlayerEntity): if not self.session: self.force_idle() if not self.device: - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True try: device_url = self.device.url("/") @@ -207,25 +211,15 @@ class PlexMediaPlayer(MediaPlayerEntity): if self.username and self.username != self.plex_server.owner: # Prepend username for shared/managed clients name_parts.insert(0, self.username) - self._name = NAME_FORMAT.format(" - ".join(name_parts)) + self._attr_name = NAME_FORMAT.format(" - ".join(name_parts)) def force_idle(self): """Force client to idle.""" - self._state = STATE_IDLE + self._attr_state = STATE_IDLE if self.player_source == "session": self.device = None self.session_device = None - self._available = False - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False - - @property - def unique_id(self): - """Return the id of this plex client.""" - return f"{self.plex_server.machine_identifier}:{self.machine_identifier}" + self._attr_available = False @property def session(self): @@ -239,17 +233,7 @@ class PlexMediaPlayer(MediaPlayerEntity): self.session_device = self.session.player self.update_state(self.session.state) else: - self._state = STATE_IDLE - - @property - def available(self): - """Return the availability of the client.""" - return self._available - - @property - def name(self): - """Return the name of the device.""" - return self._name + self._attr_state = STATE_IDLE @property @needs_session @@ -257,22 +241,17 @@ class PlexMediaPlayer(MediaPlayerEntity): """Return the username of the client owner.""" return self.session.username - @property - def state(self): - """Return the state of the device.""" - return self._state - def update_state(self, state): """Set the state of the device, handle session termination.""" if state == "playing": - self._state = STATE_PLAYING + self._attr_state = STATE_PLAYING elif state == "paused": - self._state = STATE_PAUSED + self._attr_state = STATE_PAUSED elif state == "stopped": self.session = None self.force_idle() else: - self._state = STATE_IDLE + self._attr_state = STATE_IDLE @property def _is_player_active(self): @@ -530,13 +509,13 @@ class PlexMediaPlayer(MediaPlayerEntity): def extra_state_attributes(self): """Return the scene state attributes.""" attributes = {} - for attr in [ + for attr in ( "media_content_rating", "media_library_title", "player_source", "media_summary", "username", - ]: + ): value = getattr(self, attr, None) if value: attributes[attr] = value diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 7b01d48c862..8ca72e8fb83 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -58,10 +58,13 @@ class PlexSensor(SensorEntity): def __init__(self, hass, plex_server): """Initialize the sensor.""" - self._state = None + self._attr_icon = "mdi:plex" + self._attr_name = NAME_FORMAT.format(plex_server.friendly_name) + self._attr_should_poll = False + self._attr_unique_id = f"sensor-{plex_server.machine_identifier}" + self._attr_unit_of_measurement = "Watching" + self._server = plex_server - self._name = NAME_FORMAT.format(plex_server.friendly_name) - self._unique_id = f"sensor-{plex_server.machine_identifier}" self.async_refresh_sensor = Debouncer( hass, _LOGGER, @@ -84,39 +87,9 @@ class PlexSensor(SensorEntity): async def _async_refresh_sensor(self): """Set instance object and trigger an entity state update.""" _LOGGER.debug("Refreshing sensor [%s]", self.unique_id) - self._state = len(self._server.sensor_attributes) + self._attr_state = len(self._server.sensor_attributes) self.async_write_ha_state() - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the id of this plex client.""" - return self._unique_id - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return "Watching" - - @property - def icon(self): - """Return the icon of the sensor.""" - return "mdi:plex" - @property def extra_state_attributes(self): """Return the state attributes.""" @@ -147,11 +120,15 @@ class PlexLibrarySectionSensor(SensorEntity): self.server_id = plex_server.machine_identifier self.library_section = plex_library_section self.library_type = plex_library_section.type - self._name = f"{self.server_name} Library - {plex_library_section.title}" - self._unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._state = None - self._available = True - self._attributes = {} + + self._attr_available = True + self._attr_entity_registry_enabled_default = False + self._attr_extra_state_attributes = {} + self._attr_icon = LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex") + self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" + self._attr_should_poll = False + self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" + self._attr_unit_of_measurement = "Items" async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -169,16 +146,16 @@ class PlexLibrarySectionSensor(SensorEntity): _LOGGER.debug("Refreshing library sensor for '%s'", self.name) try: await self.hass.async_add_executor_job(self._update_state_and_attrs) - self._available = True + self._attr_available = True except NotFound: - self._available = False + self._attr_available = False except requests.exceptions.RequestException as err: _LOGGER.error( "Could not update library sensor for '%s': %s", self.library_section.title, err, ) - self._available = False + self._attr_available = False self.async_write_ha_state() def _update_state_and_attrs(self): @@ -187,59 +164,16 @@ class PlexLibrarySectionSensor(SensorEntity): self.library_type, self.library_type ) - self._state = self.library_section.totalViewSize( + self._attr_state = self.library_section.totalViewSize( libtype=primary_libtype, includeCollections=False ) for libtype in LIBRARY_ATTRIBUTE_TYPES.get(self.library_type, []): - self._attributes[f"{libtype}s"] = self.library_section.totalViewSize( + self._attr_extra_state_attributes[ + f"{libtype}s" + ] = self.library_section.totalViewSize( libtype=libtype, includeCollections=False ) - @property - def available(self): - """Return the availability of the client.""" - return self._available - - @property - def entity_registry_enabled_default(self): - """Return if sensor should be enabled by default.""" - return False - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unique_id(self): - """Return the id of this plex client.""" - return self._unique_id - - @property - def should_poll(self): - """Return True if entity has to be polled for state.""" - return False - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return "Items" - - @property - def icon(self): - """Return the icon of the sensor.""" - return LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex") - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - @property def device_info(self): """Return a device description for device registry.""" diff --git a/homeassistant/components/plex/translations/de.json b/homeassistant/components/plex/translations/de.json index 2ba14e65f85..130d34505d2 100644 --- a/homeassistant/components/plex/translations/de.json +++ b/homeassistant/components/plex/translations/de.json @@ -21,7 +21,7 @@ "data": { "host": "Host", "port": "Port", - "ssl": "SSL verwenden", + "ssl": "Verwendet ein SSL-Zertifikat", "token": "Token (optional)", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, @@ -35,7 +35,7 @@ "title": "Plex-Server ausw\u00e4hlen" }, "user": { - "description": "Gehen Sie zu [plex.tv] (https://plex.tv), um einen Plex-Server zu verbinden", + "description": "Gehe zu [plex.tv] (https://plex.tv), um einen Plex-Server zu verbinden", "title": "Plex Media Server" }, "user_advanced": { diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index d19c3b49920..450388b6f42 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -19,10 +19,34 @@ from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import DiscoveryInfoType -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, ZEROCONF_MAP +from .const import ( + API, + DEFAULT_PORT, + DEFAULT_SCAN_INTERVAL, + DEFAULT_USERNAME, + DOMAIN, + FLOW_NET, + FLOW_SMILE, + FLOW_STRETCH, + FLOW_TYPE, + FLOW_USB, + PW_TYPE, + SMILE, + STRETCH, + STRETCH_USERNAME, + ZEROCONF_MAP, +) _LOGGER = logging.getLogger(__name__) +CONF_MANUAL_PATH = "Enter Manually" + +CONNECTION_SCHEMA = vol.Schema( + {vol.Required(FLOW_TYPE, default=FLOW_NET): vol.In([FLOW_NET, FLOW_USB])} +) + +# PLACEHOLDER USB connection validation + def _base_gw_schema(discovery_info): """Generate base schema for gateways.""" @@ -31,22 +55,18 @@ def _base_gw_schema(discovery_info): if not discovery_info: base_gw_schema[vol.Required(CONF_HOST)] = str base_gw_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + base_gw_schema[vol.Required(CONF_USERNAME, default=SMILE)] = vol.In( + {SMILE: FLOW_SMILE, STRETCH: FLOW_STRETCH} + ) - base_gw_schema.update( - { - vol.Required( - CONF_USERNAME, default="smile", description={"suggested_value": "smile"} - ): str, - vol.Required(CONF_PASSWORD): str, - } - ) + base_gw_schema.update({vol.Required(CONF_PASSWORD): str}) return vol.Schema(base_gw_schema) async def validate_gw_input(hass: core.HomeAssistant, data): """ - Validate whether the user input allows us to connect to the gateray. + Validate whether the user input allows us to connect to the gateway. Data has the keys from _base_gw_schema() with values provided by the user. """ @@ -71,9 +91,6 @@ async def validate_gw_input(hass: core.HomeAssistant, data): return api -# PLACEHOLDER USB connection validation - - class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Plugwise Smile.""" @@ -86,34 +103,42 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): """Prepare configuration for a discovered Plugwise Smile.""" self.discovery_info = discovery_info + self.discovery_info[CONF_USERNAME] = DEFAULT_USERNAME _properties = self.discovery_info.get("properties") + # unique_id is needed here, to be able to determine whether the discovered device is known, or not. unique_id = self.discovery_info.get("hostname").split(".")[0] await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() + if DEFAULT_USERNAME not in unique_id: + self.discovery_info[CONF_USERNAME] = STRETCH_USERNAME _product = _properties.get("product", None) _version = _properties.get("version", "n/a") _name = f"{ZEROCONF_MAP.get(_product, _product)} v{_version}" self.context["title_placeholders"] = { - CONF_HOST: discovery_info[CONF_HOST], - CONF_PORT: discovery_info.get(CONF_PORT, DEFAULT_PORT), + CONF_HOST: self.discovery_info[CONF_HOST], CONF_NAME: _name, + CONF_PORT: self.discovery_info[CONF_PORT], + CONF_USERNAME: self.discovery_info[CONF_USERNAME], } - return await self.async_step_user() + return await self.async_step_user_gateway() + + # PLACEHOLDER USB step_user async def async_step_user_gateway(self, user_input=None): - """Handle the initial step for gateways.""" + """Handle the initial step when using network/gateway setups.""" + api = None errors = {} if user_input is not None: + user_input.pop(FLOW_TYPE, None) if self.discovery_info: user_input[CONF_HOST] = self.discovery_info[CONF_HOST] - user_input[CONF_PORT] = self.discovery_info.get(CONF_PORT, DEFAULT_PORT) - - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + user_input[CONF_PORT] = self.discovery_info[CONF_PORT] + user_input[CONF_USERNAME] = self.discovery_info[CONF_USERNAME] try: api = await validate_gw_input(self.hass, user_input) @@ -125,26 +150,36 @@ class PlugwiseConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors[CONF_BASE] = "unknown" + if not errors: await self.async_set_unique_id( api.smile_hostname or api.gateway_id, raise_on_progress=False ) self._abort_if_unique_id_configured() + user_input[PW_TYPE] = API return self.async_create_entry(title=api.smile_name, data=user_input) return self.async_show_form( step_id="user_gateway", data_schema=_base_gw_schema(self.discovery_info), - errors=errors or {}, + errors=errors, ) - # PLACEHOLDER USB async_step_user_usb and async_step_user_usb_manual_paht - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - # PLACEHOLDER USB vs Gateway Logic - return await self.async_step_user_gateway() + """Handle the initial step when using network/gateway setups.""" + errors = {} + if user_input is not None: + if user_input[FLOW_TYPE] == FLOW_NET: + return await self.async_step_user_gateway() + + # PLACEHOLDER for USB_FLOW + + return self.async_show_form( + step_id="user", + data_schema=CONNECTION_SCHEMA, + errors=errors, + ) @staticmethod @callback @@ -165,8 +200,9 @@ class PlugwiseOptionsFlowHandler(config_entries.OptionsFlow): if user_input is not None: return self.async_create_entry(title="", data=user_input) - api = self.hass.data[DOMAIN][self.config_entry.entry_id]["api"] + api = self.hass.data[DOMAIN][self.config_entry.entry_id][API] interval = DEFAULT_SCAN_INTERVAL[api.smile_type] + data = { vol.Optional( CONF_SCAN_INTERVAL, diff --git a/homeassistant/components/plugwise/const.py b/homeassistant/components/plugwise/const.py index fb8911d6fc7..a7a5c92a21c 100644 --- a/homeassistant/components/plugwise/const.py +++ b/homeassistant/components/plugwise/const.py @@ -1,10 +1,33 @@ -"""Constant for Plugwise component.""" -DOMAIN = "plugwise" +"""Constants for Plugwise component.""" -SENSOR_PLATFORMS = ["sensor", "switch"] -PLATFORMS_GATEWAY = ["binary_sensor", "climate", "sensor", "switch"] -PW_TYPE = "plugwise_type" +API = "api" +ATTR_ILLUMINANCE = "illuminance" +COORDINATOR = "coordinator" +DEVICE_STATE = "device_state" +DOMAIN = "plugwise" +FLOW_NET = "Network: Smile/Stretch" +FLOW_SMILE = "smile (Adam/Anna/P1)" +FLOW_STRETCH = "stretch (Stretch)" +FLOW_TYPE = "flow_type" +FLOW_USB = "USB: Stick - Coming soon" GATEWAY = "gateway" +PW_TYPE = "plugwise_type" +SCHEDULE_OFF = "false" +SCHEDULE_ON = "true" +SMILE = "smile" +STRETCH = "stretch" +STRETCH_USERNAME = "stretch" +UNDO_UPDATE_LISTENER = "undo_update_listener" +UNIT_LUMEN = "lm" + +PLATFORMS_GATEWAY = ["binary_sensor", "climate", "sensor", "switch"] +SENSOR_PLATFORMS = ["sensor", "switch"] +ZEROCONF_MAP = { + "smile": "P1", + "smile_thermo": "Anna", + "smile_open_therm": "Adam", + "stretch": "Stretch", +} # Sensor mapping SENSOR_MAP_DEVICE_CLASS = 2 @@ -13,13 +36,17 @@ SENSOR_MAP_MODEL = 0 SENSOR_MAP_UOM = 1 # Default directives -DEFAULT_MIN_TEMP = 4 DEFAULT_MAX_TEMP = 30 +DEFAULT_MIN_TEMP = 4 DEFAULT_NAME = "Smile" DEFAULT_PORT = 80 -DEFAULT_USERNAME = "smile" -DEFAULT_SCAN_INTERVAL = {"power": 10, "stretch": 60, "thermostat": 60} +DEFAULT_SCAN_INTERVAL = { + "power": 10, + "stretch": 60, + "thermostat": 60, +} DEFAULT_TIMEOUT = 60 +DEFAULT_USERNAME = "smile" # Configuration directives CONF_GAS = "gas" @@ -28,15 +55,7 @@ CONF_MIN_TEMP = "min_temp" CONF_POWER = "power" CONF_THERMOSTAT = "thermostat" -ATTR_ILLUMINANCE = "illuminance" - -UNIT_LUMEN = "lm" - -DEVICE_STATE = "device_state" - -SCHEDULE_OFF = "false" -SCHEDULE_ON = "true" - +# Icons COOL_ICON = "mdi:snowflake" FLAME_ICON = "mdi:fire" FLOW_OFF_ICON = "mdi:water-pump-off" @@ -45,12 +64,3 @@ IDLE_ICON = "mdi:circle-off-outline" SWITCH_ICON = "mdi:electric-switch" NO_NOTIFICATION_ICON = "mdi:mailbox-outline" NOTIFICATION_ICON = "mdi:mailbox-up-outline" - -COORDINATOR = "coordinator" -UNDO_UPDATE_LISTENER = "undo_update_listener" -ZEROCONF_MAP = { - "smile": "P1", - "smile_thermo": "Anna", - "smile_open_therm": "Adam", - "stretch": "Stretch", -} diff --git a/homeassistant/components/plugwise/translations/de.json b/homeassistant/components/plugwise/translations/de.json index 9e2836202df..a5c11645d6f 100644 --- a/homeassistant/components/plugwise/translations/de.json +++ b/homeassistant/components/plugwise/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Service ist bereits konfiguriert" + "already_configured": "Der Dienst ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -25,7 +25,7 @@ "username": "Smile-Benutzername" }, "description": "Bitte eingeben", - "title": "Stellen Sie eine Verbindung zu Smile her" + "title": "Stelle eine Verbindung zu Smile her" } } }, diff --git a/homeassistant/components/plugwise/translations/he.json b/homeassistant/components/plugwise/translations/he.json index a89120b85ab..db3eeef2d53 100644 --- a/homeassistant/components/plugwise/translations/he.json +++ b/homeassistant/components/plugwise/translations/he.json @@ -10,6 +10,9 @@ }, "flow_title": "{name}", "step": { + "user": { + "description": "\u05de\u05d5\u05e6\u05e8:" + }, "user_gateway": { "data": { "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", diff --git a/homeassistant/components/plugwise/translations/hu.json b/homeassistant/components/plugwise/translations/hu.json index 3d7de972fb0..4d588c84277 100644 --- a/homeassistant/components/plugwise/translations/hu.json +++ b/homeassistant/components/plugwise/translations/hu.json @@ -28,5 +28,15 @@ "title": "Csatlakoz\u00e1s a Smile-hoz" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Szkennel\u00e9si intervallum (m\u00e1sodperc)" + }, + "description": "\u00c1ll\u00edtsa be a Plugwise lehet\u0151s\u00e9get" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/config_flow.py b/homeassistant/components/plum_lightpad/config_flow.py index 64c424ae74b..f2cc88538f9 100644 --- a/homeassistant/components/plum_lightpad/config_flow.py +++ b/homeassistant/components/plum_lightpad/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from aiohttp import ContentTypeError from requests.exceptions import ConnectTimeout, HTTPError @@ -35,7 +36,9 @@ class PlumLightpadConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user or redirected to by import.""" if not user_input: return self._show_form() diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 338ed275f50..dc34e1f9367 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRESSURE_HPA, + SOUND_PRESSURE_WEIGHTED_DBA, TEMP_CELSIUS, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -24,7 +25,7 @@ SENSOR_TYPES = { DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), DEVICE_CLASS_PRESSURE: (None, 0, PRESSURE_HPA), DEVICE_CLASS_HUMIDITY: (None, 1, PERCENTAGE), - DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, "dBa"), + DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, SOUND_PRESSURE_WEIGHTED_DBA), } diff --git a/homeassistant/components/point/translations/de.json b/homeassistant/components/point/translations/de.json index 41a8eb4344f..f0c2eee923b 100644 --- a/homeassistant/components/point/translations/de.json +++ b/homeassistant/components/point/translations/de.json @@ -12,7 +12,7 @@ }, "error": { "follow_link": "Bitte folgen dem Link und authentifiziere dich, bevor du auf Senden klickst", - "no_token": "Ung\u00fcltiger Access Token" + "no_token": "Ung\u00fcltiger Zugriffs-Token" }, "step": { "auth": { @@ -23,7 +23,7 @@ "data": { "flow_impl": "Anbieter" }, - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", "title": "W\u00e4hle die Authentifizierungsmethode" } } diff --git a/homeassistant/components/point/translations/hu.json b/homeassistant/components/point/translations/hu.json index 1fc46d0c19b..17dc73a189b 100644 --- a/homeassistant/components/point/translations/hu.json +++ b/homeassistant/components/point/translations/hu.json @@ -3,6 +3,7 @@ "abort": { "already_setup": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges.", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", + "external_setup": "A pont sikeresen konfigur\u00e1lva van egy m\u00e1sik folyamatb\u00f3l.", "no_flows": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." }, diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index ca79fde6b08..dd03111e85e 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -6,6 +6,7 @@ from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_POTENTIAL_MILLIVOLT, PERCENTAGE, TEMP_CELSIUS, ) @@ -15,7 +16,7 @@ from .const import ATTRIBUTION, DOMAIN SENSORS = { "Chlorine": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine", "device_class": None, @@ -40,13 +41,13 @@ SENSORS = { "device_class": DEVICE_CLASS_TIMESTAMP, }, "Chlorine High": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine High", "device_class": None, }, "Chlorine Low": { - "unit": "mV", + "unit": ELECTRIC_POTENTIAL_MILLIVOLT, "icon": "mdi:pool", "name": "Chlorine Low", "device_class": None, diff --git a/homeassistant/components/poolsense/translations/de.json b/homeassistant/components/poolsense/translations/de.json index dc569c2d9ad..5ce9313b442 100644 --- a/homeassistant/components/poolsense/translations/de.json +++ b/homeassistant/components/poolsense/translations/de.json @@ -12,8 +12,8 @@ "email": "E-Mail", "password": "Passwort" }, - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", - "title": "" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", + "title": "PoolSense" } } } diff --git a/homeassistant/components/powerwall/__init__.py b/homeassistant/components/powerwall/__init__.py index 3bc4dc9b035..5560c51f72b 100644 --- a/homeassistant/components/powerwall/__init__.py +++ b/homeassistant/components/powerwall/__init__.py @@ -41,6 +41,8 @@ PLATFORMS = ["binary_sensor", "sensor"] _LOGGER = logging.getLogger(__name__) +MAX_LOGIN_FAILURES = 5 + async def _migrate_old_unique_ids(hass, entry_id, powerwall_data): serial_numbers = powerwall_data[POWERWALL_API_SERIAL_NUMBERS] @@ -111,17 +113,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed from err await _migrate_old_unique_ids(hass, entry_id, powerwall_data) + login_failed_count = 0 async def async_update_data(): """Fetch data from API endpoint.""" # Check if we had an error before + nonlocal login_failed_count _LOGGER.debug("Checking if update failed") if hass.data[DOMAIN][entry.entry_id][POWERWALL_API_CHANGED]: return hass.data[DOMAIN][entry.entry_id][POWERWALL_COORDINATOR].data _LOGGER.debug("Updating data") try: - return await _async_update_powerwall_data(hass, entry, power_wall) + data = await _async_update_powerwall_data(hass, entry, power_wall) except AccessDeniedError as err: if password is None: raise ConfigEntryAuthFailed from err @@ -131,7 +135,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.async_add_executor_job(power_wall.login, "", password) return await _async_update_powerwall_data(hass, entry, power_wall) except AccessDeniedError as ex: - raise ConfigEntryAuthFailed from ex + login_failed_count += 1 + if login_failed_count == MAX_LOGIN_FAILURES: + raise ConfigEntryAuthFailed from ex + raise UpdateFailed( + f"Login attempt {login_failed_count}/{MAX_LOGIN_FAILURES} failed, will retry" + ) from ex + else: + login_failed_count = 0 + return data coordinator = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/powerwall/const.py b/homeassistant/components/powerwall/const.py index f338d5f981d..c86333cb9f8 100644 --- a/homeassistant/components/powerwall/const.py +++ b/homeassistant/components/powerwall/const.py @@ -32,5 +32,3 @@ POWERWALL_HTTP_SESSION = "http_session" MODEL = "PowerWall 2" MANUFACTURER = "Tesla" - -ENERGY_KILO_WATT = "kW" diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index d6c326593aa..d536c776bf0 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -4,7 +4,12 @@ import logging from tesla_powerwall import MeterType from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity -from homeassistant.const import DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER, PERCENTAGE +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_POWER, + PERCENTAGE, + POWER_KILO_WATT, +) from .const import ( ATTR_ENERGY_EXPORTED, @@ -14,7 +19,6 @@ from .const import ( ATTR_INSTANT_TOTAL_CURRENT, ATTR_IS_ACTIVE, DOMAIN, - ENERGY_KILO_WATT, POWERWALL_API_CHARGE, POWERWALL_API_DEVICE_TYPE, POWERWALL_API_METERS, @@ -83,7 +87,7 @@ class PowerWallEnergySensor(PowerWallEntity, SensorEntity): """Representation of an Powerwall Energy sensor.""" _attr_state_class = STATE_CLASS_MEASUREMENT - _attr_unit_of_measurement = ENERGY_KILO_WATT + _attr_unit_of_measurement = POWER_KILO_WATT _attr_device_class = DEVICE_CLASS_POWER def __init__( diff --git a/homeassistant/components/powerwall/translations/de.json b/homeassistant/components/powerwall/translations/de.json index 88b0473232f..8ac8a1a5b1d 100644 --- a/homeassistant/components/powerwall/translations/de.json +++ b/homeassistant/components/powerwall/translations/de.json @@ -18,7 +18,7 @@ "password": "Passwort" }, "description": "Das Kennwort ist in der Regel die letzten 5 Zeichen der Seriennummer des Backup Gateway und kann in der Tesla-App gefunden werden oder es sind die letzten 5 Zeichen des Kennworts, das sich in der T\u00fcr f\u00fcr Backup Gateway 2 befindet.", - "title": "Stellen Sie eine Verbindung zur Powerwall her" + "title": "Stelle eine Verbindung zur Powerwall her" } } } diff --git a/homeassistant/components/profiler/translations/de.json b/homeassistant/components/profiler/translations/de.json index 7137cd2ee4e..31df88ebe98 100644 --- a/homeassistant/components/profiler/translations/de.json +++ b/homeassistant/components/profiler/translations/de.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "M\u00f6chtest du mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/progettihwsw/translations/hu.json b/homeassistant/components/progettihwsw/translations/hu.json index f6f6e2c15b7..76af6fb124f 100644 --- a/homeassistant/components/progettihwsw/translations/hu.json +++ b/homeassistant/components/progettihwsw/translations/hu.json @@ -19,8 +19,15 @@ "relay_15": "Rel\u00e9 15", "relay_16": "Rel\u00e9 16", "relay_2": "Rel\u00e9 2", - "relay_3": "Rel\u00e9 3" - } + "relay_3": "Rel\u00e9 3", + "relay_4": "4-es rel\u00e9", + "relay_5": "5-\u00f6s rel\u00e9 ", + "relay_6": "6-os rel\u00e9", + "relay_7": "7-es rel\u00e9", + "relay_8": "8-as rel\u00e9", + "relay_9": "9-es rel\u00e9" + }, + "title": "Rel\u00e9k be\u00e1ll\u00edt\u00e1sa" }, "user": { "data": { diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index c74caa745f3..f158d2506d1 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -54,12 +54,14 @@ COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema( {vol.Optional(CONF_OVERRIDE_METRIC): cv.string} ) +DEFAULT_NAMESPACE = "homeassistant" + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( { vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, - vol.Optional(CONF_PROM_NAMESPACE): cv.string, + vol.Optional(CONF_PROM_NAMESPACE, default=DEFAULT_NAMESPACE): cv.string, vol.Optional(CONF_DEFAULT_METRIC): cv.string, vol.Optional(CONF_OVERRIDE_METRIC): cv.string, vol.Optional(CONF_COMPONENT_CONFIG, default={}): vol.Schema( @@ -291,7 +293,9 @@ class PrometheusMetrics: def _handle_light(self, state): metric = self._metric( - "light_state", self.prometheus_cli.Gauge, "Load level of a light (0..1)" + "light_brightness_percent", + self.prometheus_cli.Gauge, + "Light brightness percentage (0..100)", ) try: @@ -317,9 +321,9 @@ class PrometheusMetrics: if self._climate_units == TEMP_FAHRENHEIT: temp = fahrenheit_to_celsius(temp) metric = self._metric( - "temperature_c", + "climate_target_temperature_celsius", self.prometheus_cli.Gauge, - "Temperature in degrees Celsius", + "Target temperature in degrees Celsius", ) metric.labels(**self._labels(state)).set(temp) @@ -328,9 +332,9 @@ class PrometheusMetrics: if self._climate_units == TEMP_FAHRENHEIT: current_temp = fahrenheit_to_celsius(current_temp) metric = self._metric( - "current_temperature_c", + "climate_current_temperature_celsius", self.prometheus_cli.Gauge, - "Current Temperature in degrees Celsius", + "Current temperature in degrees Celsius", ) metric.labels(**self._labels(state)).set(current_temp) @@ -414,7 +418,7 @@ class PrometheusMetrics: """Get metric based on device class attribute.""" metric = state.attributes.get(ATTR_DEVICE_CLASS) if metric is not None: - return f"{metric}_{unit}" + return f"sensor_{metric}_{unit}" return None def _sensor_override_metric(self, state, unit): @@ -442,8 +446,8 @@ class PrometheusMetrics: return units = { - TEMP_CELSIUS: "c", - TEMP_FAHRENHEIT: "c", # F should go into C metric + TEMP_CELSIUS: "celsius", + TEMP_FAHRENHEIT: "celsius", # F should go into C metric PERCENTAGE: "percent", } default = unit.replace("/", "_per_") diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py new file mode 100644 index 00000000000..3e31a1142ce --- /dev/null +++ b/homeassistant/components/prosegur/__init__.py @@ -0,0 +1,52 @@ +"""The Prosegur Alarm integration.""" +import logging + +from pyprosegur.auth import Auth + +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 aiohttp_client + +from .const import CONF_COUNTRY, DOMAIN + +PLATFORMS = ["alarm_control_panel"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Prosegur Alarm from a config entry.""" + try: + session = aiohttp_client.async_get_clientsession(hass) + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = Auth( + session, + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + entry.data[CONF_COUNTRY], + ) + await hass.data[DOMAIN][entry.entry_id].login() + + except ConnectionRefusedError as error: + _LOGGER.error("Configured credential are invalid, %s", error) + + raise ConfigEntryAuthFailed from error + + except ConnectionError as error: + _LOGGER.error("Could not connect with Prosegur backend: %s", error) + raise ConfigEntryNotReady from error + + hass.config_entries.async_setup_platforms(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/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py new file mode 100644 index 00000000000..a61f91830c5 --- /dev/null +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -0,0 +1,76 @@ +"""Support for Prosegur alarm control panels.""" +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 ( + SUPPORT_ALARM_ARM_AWAY, + SUPPORT_ALARM_ARM_HOME, +) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) + +from . import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STATE_MAPPING = { + Status.DISARMED: STATE_ALARM_DISARMED, + Status.ARMED: STATE_ALARM_ARMED_AWAY, + Status.PARTIALLY: STATE_ALARM_ARMED_HOME, + Status.ERROR_PARTIALLY: STATE_ALARM_ARMED_HOME, +} + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the Prosegur alarm control panel platform.""" + async_add_entities( + [ProsegurAlarm(entry.data["contract"], hass.data[DOMAIN][entry.entry_id])], + update_before_add=True, + ) + + +class ProsegurAlarm(alarm.AlarmControlPanelEntity): + """Representation of a Prosegur alarm status.""" + + def __init__(self, contract: str, auth: Auth) -> None: + """Initialize the Prosegur alarm panel.""" + self._changed_by = None + + self._installation = None + self.contract = contract + self._auth = auth + + self._attr_name = f"contract {self.contract}" + self._attr_unique_id = self.contract + self._attr_supported_features = SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME + + async def async_update(self): + """Update alarm status.""" + + try: + self._installation = await Installation.retrieve(self._auth) + except ConnectionError as err: + _LOGGER.error(err) + self._attr_available = False + return + + self._attr_state = STATE_MAPPING.get(self._installation.status) + self._attr_available = True + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._installation.disarm(self._auth) + + async def async_alarm_arm_home(self, code=None): + """Send arm away command.""" + await self._installation.arm_partially(self._auth) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._installation.arm(self._auth) diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py new file mode 100644 index 00000000000..1807561663b --- /dev/null +++ b/homeassistant/components/prosegur/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow for Prosegur Alarm integration.""" +import logging + +from pyprosegur.auth import COUNTRY, Auth +from pyprosegur.installation import Installation +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import aiohttp_client + +from .const import CONF_COUNTRY, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Required(CONF_COUNTRY): vol.In(COUNTRY.keys()), + } +) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect.""" + session = aiohttp_client.async_get_clientsession(hass) + auth = Auth(session, data[CONF_USERNAME], data[CONF_PASSWORD], data[CONF_COUNTRY]) + try: + install = await Installation.retrieve(auth) + except ConnectionRefusedError: + raise InvalidAuth from ConnectionRefusedError + except ConnectionError: + raise CannotConnect from ConnectionError + + # Info to store in the config entry. + return { + "title": f"Contract {install.contract}", + "contract": install.contract, + "username": data[CONF_USERNAME], + "password": data[CONF_PASSWORD], + "country": data[CONF_COUNTRY], + } + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Prosegur Alarm.""" + + VERSION = 1 + entry: ConfigEntry + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as exception: # pylint: disable=broad-except + _LOGGER.exception(exception) + errors["base"] = "unknown" + else: + await self.async_set_unique_id(info["contract"]) + self._abort_if_unique_id_configured() + + user_input["contract"] = info["contract"] + return self.async_create_entry(title=info["title"], 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, data): + """Handle initiation of re-authentication with Prosegur.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Handle re-authentication with Prosegur.""" + errors = {} + + if user_input: + try: + user_input[CONF_COUNTRY] = self.entry.data[CONF_COUNTRY] + await validate_input(self.hass, user_input) + + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.config_entries.async_update_entry( + self.entry, + data={ + **self.entry.data, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_USERNAME, default=self.entry.data[CONF_USERNAME] + ): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + +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/prosegur/const.py b/homeassistant/components/prosegur/const.py new file mode 100644 index 00000000000..b066b320a17 --- /dev/null +++ b/homeassistant/components/prosegur/const.py @@ -0,0 +1,5 @@ +"""Constants for the Prosegur Alarm integration.""" + +DOMAIN = "prosegur" + +CONF_COUNTRY = "country" diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json new file mode 100644 index 00000000000..853324c9408 --- /dev/null +++ b/homeassistant/components/prosegur/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "prosegur", + "name": "Prosegur Alarm", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/prosegur", + "requirements": [ + "pyprosegur==0.0.5" + ], + "codeowners": [ + "@dgomes" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/garmin_connect/strings.json b/homeassistant/components/prosegur/strings.json similarity index 56% rename from homeassistant/components/garmin_connect/strings.json rename to homeassistant/components/prosegur/strings.json index 0ec7a3ce04c..919628c7510 100644 --- a/homeassistant/components/garmin_connect/strings.json +++ b/homeassistant/components/prosegur/strings.json @@ -1,23 +1,29 @@ { "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "country": "Country" + } + }, + "reauth_confirm": { + "data": { + "description": "Re-authenticate with Prosegur account.", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "too_many_requests": "Too many requests, retry later.", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "step": { - "user": { - "data": { - "password": "[%key:common::config_flow::data::password%]", - "username": "[%key:common::config_flow::data::username%]" - }, - "description": "Enter your credentials.", - "title": "Garmin Connect" - } + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/ca.json b/homeassistant/components/prosegur/translations/ca.json new file mode 100644 index 00000000000..a6c7c925a25 --- /dev/null +++ b/homeassistant/components/prosegur/translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Torna a autenticar-te amb el compte de Prosegur.", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + }, + "user": { + "data": { + "country": "Pa\u00eds", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/translations/cs.json b/homeassistant/components/prosegur/translations/cs.json similarity index 53% rename from homeassistant/components/garmin_connect/translations/cs.json rename to homeassistant/components/prosegur/translations/cs.json index 86b0ce1ddef..13c0827ff40 100644 --- a/homeassistant/components/garmin_connect/translations/cs.json +++ b/homeassistant/components/prosegur/translations/cs.json @@ -1,22 +1,26 @@ { "config": { "abort": { - "already_configured": "\u00da\u010det je ji\u017e nastaven" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed", - "too_many_requests": "P\u0159\u00edli\u0161 mnoho po\u017eadavk\u016f, opakujte to pozd\u011bji.", "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, "step": { + "reauth_confirm": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, "user": { "data": { "password": "Heslo", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" - }, - "description": "Zadejte sv\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje.", - "title": "Garmin Connect" + } } } } diff --git a/homeassistant/components/prosegur/translations/de.json b/homeassistant/components/prosegur/translations/de.json new file mode 100644 index 00000000000..aa5667d8d54 --- /dev/null +++ b/homeassistant/components/prosegur/translations/de.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Authentifiziere dich erneut mit deinem Prosegur-Konto.", + "password": "Passwort", + "username": "Benutzername" + } + }, + "user": { + "data": { + "country": "Land", + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/en.json b/homeassistant/components/prosegur/translations/en.json new file mode 100644 index 00000000000..a1ced2173c7 --- /dev/null +++ b/homeassistant/components/prosegur/translations/en.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Re-authenticate with Prosegur account.", + "password": "Password", + "username": "Username" + } + }, + "user": { + "data": { + "country": "Country", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/et.json b/homeassistant/components/prosegur/translations/et.json new file mode 100644 index 00000000000..bc5a1a5a7ea --- /dev/null +++ b/homeassistant/components/prosegur/translations/et.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Tundmatu viga" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Taastuvasta oma Prosegur kontoga.", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + }, + "user": { + "data": { + "country": "Riik", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/fr.json b/homeassistant/components/prosegur/translations/fr.json new file mode 100644 index 00000000000..7c0d361da6a --- /dev/null +++ b/homeassistant/components/prosegur/translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "R\u00e9-authentifiez-vous avec le compte Prosegur.", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, + "user": { + "data": { + "country": "Pays", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/he.json b/homeassistant/components/prosegur/translations/he.json new file mode 100644 index 00000000000..89a7445f2c3 --- /dev/null +++ b/homeassistant/components/prosegur/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "country": "\u05de\u05d3\u05d9\u05e0\u05d4", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/it.json b/homeassistant/components/prosegur/translations/it.json new file mode 100644 index 00000000000..79409045d7b --- /dev/null +++ b/homeassistant/components/prosegur/translations/it.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Eseguire nuovamente l'autenticazione con l'account Prosegur.", + "password": "Password", + "username": "Nome utente" + } + }, + "user": { + "data": { + "country": "Nazione", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/nl.json b/homeassistant/components/prosegur/translations/nl.json new file mode 100644 index 00000000000..d87556b0742 --- /dev/null +++ b/homeassistant/components/prosegur/translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Verifieer opnieuw met Prosegur-account.", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + }, + "user": { + "data": { + "country": "Land", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/no.json b/homeassistant/components/prosegur/translations/no.json new file mode 100644 index 00000000000..5732bb920b2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + }, + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/pl.json b/homeassistant/components/prosegur/translations/pl.json new file mode 100644 index 00000000000..342c17222b2 --- /dev/null +++ b/homeassistant/components/prosegur/translations/pl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "Ponownie uwierzytelnij za pomoc\u0105 konta Prosegur.", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "country": "Kraj", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/ru.json b/homeassistant/components/prosegur/translations/ru.json new file mode 100644 index 00000000000..c75f3e572c6 --- /dev/null +++ b/homeassistant/components/prosegur/translations/ru.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.", + "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Prosegur.", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + }, + "user": { + "data": { + "country": "\u0421\u0442\u0440\u0430\u043d\u0430", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/zh-Hant.json b/homeassistant/components/prosegur/translations/zh-Hant.json new file mode 100644 index 00000000000..501e979448e --- /dev/null +++ b/homeassistant/components/prosegur/translations/zh-Hant.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "reauth_confirm": { + "data": { + "description": "\u91cd\u65b0\u8a8d\u8b49 Prosegur \u5e33\u865f\u3002", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + }, + "user": { + "data": { + "country": "\u570b\u5bb6", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index de9d6247f9f..1840162f896 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -230,10 +230,10 @@ class Proximity(Entity): closest_device: str = None dist_to_zone: float = None - for device in distances_to_zone: - if not dist_to_zone or distances_to_zone[device] < dist_to_zone: + for device, zone in distances_to_zone.items(): + if not dist_to_zone or zone < dist_to_zone: closest_device = device - dist_to_zone = distances_to_zone[device] + dist_to_zone = zone # If the closest device is one of the other devices. if closest_device != entity: diff --git a/homeassistant/components/proximity/translations/he.json b/homeassistant/components/proximity/translations/he.json index 1e840a5f52e..de60856aab1 100644 --- a/homeassistant/components/proximity/translations/he.json +++ b/homeassistant/components/proximity/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05e7\u05b4\u05e8\u05d1\u05b8\u05d4" + "title": "\u05e7\u05d9\u05e8\u05d1\u05d4" } \ No newline at end of file diff --git a/homeassistant/components/ps4/translations/de.json b/homeassistant/components/ps4/translations/de.json index 1a20740dfb1..ca1a0ab24b1 100644 --- a/homeassistant/components/ps4/translations/de.json +++ b/homeassistant/components/ps4/translations/de.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Ger\u00e4t ist bereits konfiguriert", "credential_error": "Fehler beim Abrufen der Anmeldeinformationen.", - "no_devices_found": "Es wurden keine PlayStation 4 im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "port_987_bind_error": "Konnte sich nicht an Port 987 binden. Weitere Informationen findest du in der [Dokumentation] (https://www.home-assistant.io/components/ps4/).", "port_997_bind_error": "Bind to Port 997 nicht m\u00f6glich. Weitere Informationen findest du in der [Dokumentation](https://www.home-assistant.io/components/ps4/)" }, @@ -25,7 +25,7 @@ "name": "Name", "region": "Region" }, - "description": "Gib deine PlayStation 4-Informationen ein. Navigiere f\u00fcr \"PIN\" auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN ein. Weitere Informationen finden Sie in der [Dokumentation](https://www.home-assistant.io/components/ps4/).", + "description": "Gib deine PlayStation 4-Informationen ein. Navigiere f\u00fcr den PIN-Code auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN-Code ein. Weitere Informationen findest du in der [Dokumentation](https://www.home-assistant.io/components/ps4/).", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/ps4/translations/he.json b/homeassistant/components/ps4/translations/he.json index 837dd13b925..e9543da8206 100644 --- a/homeassistant/components/ps4/translations/he.json +++ b/homeassistant/components/ps4/translations/he.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 \u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4 \u05d1\u05e8\u05e9\u05ea." + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" @@ -14,7 +14,7 @@ "link": { "data": { "code": "\u05e7\u05d5\u05d3 PIN", - "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d4 - IP", + "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "name": "\u05e9\u05dd", "region": "\u05d0\u05d9\u05d6\u05d5\u05e8" }, diff --git a/homeassistant/components/ps4/translations/hu.json b/homeassistant/components/ps4/translations/hu.json index bb677b21700..97614bcac57 100644 --- a/homeassistant/components/ps4/translations/hu.json +++ b/homeassistant/components/ps4/translations/hu.json @@ -2,14 +2,20 @@ "config": { "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", - "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton" + "credential_error": "Hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u0151 adatok beolvas\u00e1sa sor\u00e1n.", + "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "port_987_bind_error": "Nem siker\u00fclt a 987. porthoz kapcsol\u00f3dni. Tov\u00e1bbi inform\u00e1ci\u00f3 a [dokument\u00e1ci\u00f3ban] tal\u00e1lhat\u00f3 (https://www.home-assistant.io/components/ps4/).", + "port_997_bind_error": "Nem siker\u00fclt a 997-es porthoz kapcsol\u00f3dni. Tov\u00e1bbi inform\u00e1ci\u00f3 a [dokument\u00e1ci\u00f3ban] tal\u00e1lhat\u00f3 (https://www.home-assistant.io/components/ps4/)." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "login_failed": "Nem siker\u00fclt p\u00e1ros\u00edtani a PlayStation 4-gyel. Ellen\u0151rizze, hogy a PIN-k\u00f3d helyes-e." + "credential_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s. Az \u00fajraind\u00edt\u00e1shoz nyomja meg a bek\u00fcld\u00e9s gombot.", + "login_failed": "Nem siker\u00fclt p\u00e1ros\u00edtani a PlayStation 4-gyel. Ellen\u0151rizze, hogy a PIN-k\u00f3d helyes-e.", + "no_ipaddress": "\u00cdrja be a konfigur\u00e1lni k\u00edv\u00e1nt PlayStation 4 IP c\u00edm\u00e9t" }, "step": { "creds": { + "description": "Hiteles\u00edt\u0151 adatok sz\u00fcks\u00e9gesek. Nyomja meg a \u201eK\u00fcld\u00e9s\u201d gombot, majd a PS4 2. k\u00e9perny\u0151 alkalmaz\u00e1sban friss\u00edtse az eszk\u00f6z\u00f6ket, \u00e9s a folytat\u00e1shoz v\u00e1lassza a \u201eHome-Assistant\u201d eszk\u00f6zt.", "title": "PlayStation 4" }, "link": { @@ -18,13 +24,16 @@ "ip_address": "IP c\u00edm", "name": "N\u00e9v", "region": "R\u00e9gi\u00f3" - } + }, + "description": "Adja meg a PlayStation 4 adatait. A PIN-k\u00f3d eset\u00e9ben keresse meg a PlayStation 4 konzol \u201eBe\u00e1ll\u00edt\u00e1sok\u201d elem\u00e9t. Ezut\u00e1n keresse meg a \u201eMobilalkalmaz\u00e1s-kapcsolat be\u00e1ll\u00edt\u00e1sai\u201d elemet, \u00e9s v\u00e1lassza az \u201eEszk\u00f6z hozz\u00e1ad\u00e1sa\u201d lehet\u0151s\u00e9get. \u00cdrja be a megjelen\u0151 PIN-k\u00f3d . Tov\u00e1bbi inform\u00e1ci\u00f3k a [dokument\u00e1ci\u00f3ban] tal\u00e1lhat\u00f3k (https://www.home-assistant.io/components/ps4/).", + "title": "PlayStation 4" }, "mode": { "data": { "ip_address": "IP c\u00edm (Hagyd \u00fcresen az Automatikus Felder\u00edt\u00e9s haszn\u00e1lat\u00e1hoz).", "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d" }, + "description": "V\u00e1lassza ki a m\u00f3dot a konfigur\u00e1l\u00e1shoz. Az IP c\u00edm mez\u0151 \u00fcresen maradhat, ha az Automatikus felder\u00edt\u00e9s lehet\u0151s\u00e9get v\u00e1lasztja, mivel az eszk\u00f6z\u00f6k automatikusan felfedez\u0151dnek.", "title": "PlayStation 4" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/de.json b/homeassistant/components/pvpc_hourly_pricing/translations/de.json index 04f5ddb8fba..545a3d2cd9f 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/de.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/de.json @@ -11,7 +11,7 @@ "power_p3": "Vertraglich vereinbarte Leistung f\u00fcr Talperiode P3 (kW)", "tariff": "Geltender Tarif nach geografischer Zone" }, - "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. Weitere Informationen finden Sie in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). ", + "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. Weitere Informationen findest du in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "Sensoreinrichtung" } } @@ -24,7 +24,7 @@ "power_p3": "Vertraglich vereinbarte Leistung f\u00fcr Talperiode P3 (kW)", "tariff": "Geltender Tarif nach geografischer Zone" }, - "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)](https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten.\nEine genauere Erkl\u00e4rung finden Sie in den [integration docs](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)](https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten.\nEine genauere Erkl\u00e4rung findest du in den [integration docs](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "Sensoreinrichtung" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json index 5386529e43a..f8511a80579 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/fr.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/fr.json @@ -7,11 +7,26 @@ "user": { "data": { "name": "Nom du capteur", + "power": "Puissance souscrite (kW)", + "power_p3": "Puissance souscrite pour la p\u00e9riode de vall\u00e9e P3 (kW)", "tariff": "Tarif souscrit (1, 2, ou 3 p\u00e9riodes)" }, "description": "Ce capteur utilise l'API officielle pour obtenir la [tarification horaire de l'\u00e9lectricit\u00e9 (PVPC)] (https://www.esios.ree.es/es/pvpc) en Espagne. \n Pour une explication plus pr\u00e9cise, visitez la [documentation d'int\u00e9gration] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\n S\u00e9lectionnez le tarif contract\u00e9 en fonction du nombre de p\u00e9riodes de facturation par jour: \n - 1 p\u00e9riode: normale \n - 2 p\u00e9riodes: discrimination (tarif \u00e0 la nuit) \n - 3 p\u00e9riodes: voiture \u00e9lectrique (tarif \u00e0 la nuit sur 3 p\u00e9riodes)", "title": "S\u00e9lection tarifaire" } } + }, + "options": { + "step": { + "init": { + "data": { + "power": "Puissance souscrite (kW)", + "power_p3": "Puissance souscrite pour la p\u00e9riode de vall\u00e9e P3 (kW)", + "tariff": "Tarif applicable par zone g\u00e9ographique" + }, + "description": "Ce capteur utilise l'API officielle pour obtenir [tarification horaire de l'\u00e9lectricit\u00e9 (PVPC)](https://www.esios.ree.es/es/pvpc) en Espagne.\n Pour des explications plus pr\u00e9cises, visitez les [docs d'int\u00e9gration](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Configuration du capteur" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/he.json b/homeassistant/components/pvpc_hourly_pricing/translations/he.json index 48a6eeeea33..951e9b21b2f 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/he.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/he.json @@ -3,5 +3,12 @@ "abort": { "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" } + }, + "options": { + "step": { + "init": { + "title": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05d7\u05d9\u05d9\u05e9\u05df" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json index 17bca647c18..0b980bd58e0 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/hu.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/hu.json @@ -2,11 +2,26 @@ "config": { "abort": { "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "power": "Szerz\u0151d\u00e9s szerinti teljes\u00edtm\u00e9ny (kW)", + "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)" + }, + "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/)." + } } }, "options": { "step": { "init": { + "data": { + "power": "Szerz\u0151d\u00e9s szerinti teljes\u00edtm\u00e9ny (kW)", + "power_p3": "Szerz\u0151d\u00f6tt teljes\u00edtm\u00e9ny P3 v\u00f6lgyid\u0151szakra (kW)", + "tariff": "Alkalmazand\u00f3 tarifa f\u00f6ldrajzi z\u00f3n\u00e1k szerint" + }, + "description": "Ez az \u00e9rz\u00e9kel\u0151 a hivatalos API-t haszn\u00e1lja a [villamos energia \u00f3r\u00e1nk\u00e9nti \u00e1raz\u00e1s\u00e1nak (PVPC)] (https://www.esios.ree.es/es/pvpc) megszerz\u00e9s\u00e9hez Spanyolorsz\u00e1gban.\n Pontosabb magyar\u00e1zat\u00e9rt keresse fel az [integr\u00e1ci\u00f3s dokumentumok] oldalt (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", "title": "\u00c9rz\u00e9kel\u0151 be\u00e1ll\u00edt\u00e1sa" } } diff --git a/homeassistant/components/pvpc_hourly_pricing/translations/nl.json b/homeassistant/components/pvpc_hourly_pricing/translations/nl.json index f74662b06da..b4bc784dedf 100644 --- a/homeassistant/components/pvpc_hourly_pricing/translations/nl.json +++ b/homeassistant/components/pvpc_hourly_pricing/translations/nl.json @@ -9,10 +9,10 @@ "name": "Sensornaam", "power": "Gecontracteerd vermogen (kW)", "power_p3": "Gecontracteerd vermogen voor dalperiode P3 (kW)", - "tariff": "Gecontracteerd tarief (1, 2 of 3 periodes)" + "tariff": "Toepasselijk tarief per geografische zone" }, - "description": "Deze sensor gebruikt de offici\u00eble API om [uurtarief voor elektriciteit (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanje te krijgen. \n Bezoek voor een meer precieze uitleg de [integratiedocumenten] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\n Selecteer het gecontracteerde tarief op basis van het aantal factureringsperioden per dag: \n - 1 periode: normaal \n - 2 periodes: discriminatie (nachttarief) \n - 3 periodes: elektrische auto (nachttarief van 3 periodes)", - "title": "Tariefselectie" + "description": "Deze sensor gebruikt de offici\u00eble API om [uurprijs van elektriciteit (PVPC)](https://www.esios.ree.es/es/pvpc) in Spanje te verkrijgen.\n Ga voor een nauwkeurigere uitleg naar de [integratiedocumenten](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).", + "title": "Sensor instellen" } } }, diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 2051b32b63f..89a7ab4ba04 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -139,7 +139,7 @@ def execute_script(hass, name, data=None): """Execute a script.""" filename = f"{name}.py" raise_if_invalid_filename(filename) - with open(hass.config.path(FOLDER, filename)) as fil: + with open(hass.config.path(FOLDER, filename), encoding="utf8") as fil: source = fil.read() execute(hass, filename, source, data) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 5759713e80c..c175d89f60e 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, DATA_GIBIBYTES, DATA_RATE_MEBIBYTES_PER_SECOND, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) @@ -56,31 +57,46 @@ NOTIFICATION_ID = "qnap_notification" NOTIFICATION_TITLE = "QNAP Sensor Setup" _SYSTEM_MON_COND = { - "status": ["Status", None, "mdi:checkbox-marked-circle-outline"], - "system_temp": ["System Temperature", TEMP_CELSIUS, "mdi:thermometer"], + "status": ["Status", None, "mdi:checkbox-marked-circle-outline", None], + "system_temp": ["System Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], } _CPU_MON_COND = { - "cpu_temp": ["CPU Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "cpu_usage": ["CPU Usage", PERCENTAGE, "mdi:chip"], + "cpu_temp": ["CPU Temperature", TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + "cpu_usage": ["CPU Usage", PERCENTAGE, "mdi:chip", None], } _MEMORY_MON_COND = { - "memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory"], - "memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory"], - "memory_percent_used": ["Memory Usage", PERCENTAGE, "mdi:memory"], + "memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory", None], + "memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory", None], + "memory_percent_used": ["Memory Usage", PERCENTAGE, "mdi:memory", None], } _NETWORK_MON_COND = { - "network_link_status": ["Network Link", None, "mdi:checkbox-marked-circle-outline"], - "network_tx": ["Network Up", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:upload"], - "network_rx": ["Network Down", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:download"], + "network_link_status": [ + "Network Link", + None, + "mdi:checkbox-marked-circle-outline", + None, + ], + "network_tx": ["Network Up", DATA_RATE_MEBIBYTES_PER_SECOND, "mdi:upload", None], + "network_rx": [ + "Network Down", + DATA_RATE_MEBIBYTES_PER_SECOND, + "mdi:download", + None, + ], } _DRIVE_MON_COND = { - "drive_smart_status": ["SMART Status", None, "mdi:checkbox-marked-circle-outline"], - "drive_temp": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], + "drive_smart_status": [ + "SMART Status", + None, + "mdi:checkbox-marked-circle-outline", + None, + ], + "drive_temp": ["Temperature", TEMP_CELSIUS, None, None, DEVICE_CLASS_TEMPERATURE], } _VOLUME_MON_COND = { - "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie"], - "volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie"], - "volume_percentage_used": ["Volume Used", PERCENTAGE, "mdi:chart-pie"], + "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie", None], + "volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie", None], + "volume_percentage_used": ["Volume Used", PERCENTAGE, "mdi:chart-pie", None], } _MONITORED_CONDITIONS = ( @@ -210,6 +226,7 @@ class QNAPSensor(SensorEntity): self.var_icon = variable_info[2] self.monitor_device = monitor_device self._api = api + self._attr_device_class = variable_info[3] @property def name(self): diff --git a/homeassistant/components/rachio/translations/de.json b/homeassistant/components/rachio/translations/de.json index 9acd92ce40d..02a61e8f573 100644 --- a/homeassistant/components/rachio/translations/de.json +++ b/homeassistant/components/rachio/translations/de.json @@ -14,7 +14,7 @@ "api_key": "API-Schl\u00fcssel" }, "description": "Du ben\u00f6tigst den API-Schl\u00fcssel von https://app.rach.io/. Gehe in die Einstellungen und klicke auf \"API-SCHL\u00dcSSEL ANFORDERN\".", - "title": "Stellen Sie eine Verbindung zu Ihrem Rachio-Ger\u00e4t her" + "title": "Stelle eine Verbindung zu deinem Rachio-Ger\u00e4t her" } } }, diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index fda7a37756b..add2580ee87 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -207,7 +207,7 @@ class RadarrSensor(SensorEntity): filter(lambda x: x["path"] in self.included, res.json()) ) self._state = "{:.2f}".format( - to_unit(sum([data["freeSpace"] for data in self.data]), self._unit) + to_unit(sum(data["freeSpace"] for data in self.data), self._unit) ) elif self.type == "status": self.data = res.json() diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index aad6bf3989e..fc108af56a7 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -80,6 +80,8 @@ PRESET_MODE_TO_CODE = {"home": 0, "alternate": 1, "away": 2, "holiday": 3} CODE_TO_PRESET_MODE = {0: "home", 1: "alternate", 2: "away", 3: "holiday"} +CODE_TO_HOLD_STATE = {0: False, 1: True} + def round_temp(temperature): """Round a temperature to the resolution of the thermostat. @@ -300,6 +302,7 @@ class RadioThermostat(ClimateEntity): self._fstate = CODE_TO_FAN_STATE[data["fstate"]] self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] + self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] self._current_operation = self._tmode if self._tmode == HVAC_MODE_COOL: diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index d8334470b60..6aa6e837abe 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,10 +1,14 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" +from __future__ import annotations + import logging from pyrainbird import RainbirdController import voluptuous as vol from homeassistant.components import binary_sensor, sensor, switch +from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.sensor import SensorEntityDescription from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_HOST, @@ -26,11 +30,33 @@ DOMAIN = "rainbird" SENSOR_TYPE_RAINDELAY = "raindelay" SENSOR_TYPE_RAINSENSOR = "rainsensor" -# sensor_type [ description, unit, icon ] -SENSOR_TYPES = { - SENSOR_TYPE_RAINSENSOR: ["Rainsensor", None, "mdi:water"], - SENSOR_TYPE_RAINDELAY: ["Raindelay", None, "mdi:water-off"], -} + + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key=SENSOR_TYPE_RAINSENSOR, + name="Rainsensor", + icon="mdi:water", + ), + SensorEntityDescription( + key=SENSOR_TYPE_RAINDELAY, + name="Raindelay", + icon="mdi:water-off", + ), +) + +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key=SENSOR_TYPE_RAINSENSOR, + name="Rainsensor", + icon="mdi:water", + ), + BinarySensorEntityDescription( + key=SENSOR_TYPE_RAINDELAY, + name="Raindelay", + icon="mdi:water-off", + ), +) TRIGGER_TIME_SCHEMA = vol.All( cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 62c6824f5e0..476c2cfc8a2 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -3,14 +3,17 @@ import logging from pyrainbird import RainbirdController -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) from . import ( + BINARY_SENSOR_TYPES, DATA_RAINBIRD, RAINBIRD_CONTROLLER, SENSOR_TYPE_RAINDELAY, SENSOR_TYPE_RAINSENSOR, - SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) @@ -23,42 +26,32 @@ def setup_platform(hass, config, add_entities, discovery_info=None): controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] add_entities( - [RainBirdSensor(controller, sensor_type) for sensor_type in SENSOR_TYPES], True + [ + RainBirdSensor(controller, description) + for description in BINARY_SENSOR_TYPES + ], + True, ) class RainBirdSensor(BinarySensorEntity): """A sensor implementation for Rain Bird device.""" - def __init__(self, controller: RainbirdController, sensor_type): + def __init__( + self, + controller: RainbirdController, + description: BinarySensorEntityDescription, + ) -> None: """Initialize the Rain Bird sensor.""" - self._sensor_type = sensor_type + self.entity_description = description self._controller = controller - self._name = SENSOR_TYPES[self._sensor_type][0] - self._icon = SENSOR_TYPES[self._sensor_type][2] - self._state = None - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return None if self._state is None else bool(self._state) - - def update(self): + def update(self) -> None: """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor: %s", self._name) + _LOGGER.debug("Updating sensor: %s", self.name) state = None - if self._sensor_type == SENSOR_TYPE_RAINSENSOR: + if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: state = self._controller.get_rain_sensor_state() - elif self._sensor_type == SENSOR_TYPE_RAINDELAY: + elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: state = self._controller.get_rain_delay() - self._state = None if state is None else bool(state) - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def icon(self): - """Return icon.""" - return self._icon + self._attr_is_on = None if state is None else bool(state) diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 2c542dc12a9..2158bc5cf97 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -3,7 +3,7 @@ import logging from pyrainbird import RainbirdController -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from . import ( DATA_RAINBIRD, @@ -24,46 +24,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): controller = hass.data[DATA_RAINBIRD][discovery_info[RAINBIRD_CONTROLLER]] add_entities( - [RainBirdSensor(controller, sensor_type) for sensor_type in SENSOR_TYPES], True + [RainBirdSensor(controller, description) for description in SENSOR_TYPES], + True, ) class RainBirdSensor(SensorEntity): """A sensor implementation for Rain Bird device.""" - def __init__(self, controller: RainbirdController, sensor_type): + def __init__( + self, + controller: RainbirdController, + description: SensorEntityDescription, + ) -> None: """Initialize the Rain Bird sensor.""" - self._sensor_type = sensor_type + self.entity_description = description self._controller = controller - self._name = SENSOR_TYPES[self._sensor_type][0] - self._icon = SENSOR_TYPES[self._sensor_type][2] - self._unit_of_measurement = SENSOR_TYPES[self._sensor_type][1] - self._state = None - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): + def update(self) -> None: """Get the latest data and updates the states.""" - _LOGGER.debug("Updating sensor: %s", self._name) - if self._sensor_type == SENSOR_TYPE_RAINSENSOR: - self._state = self._controller.get_rain_sensor_state() - elif self._sensor_type == SENSOR_TYPE_RAINDELAY: - self._state = self._controller.get_rain_delay() - - @property - def name(self): - """Return the name of this camera.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement - - @property - def icon(self): - """Return icon.""" - return self._icon + _LOGGER.debug("Updating sensor: %s", self.name) + if self.entity_description.key == SENSOR_TYPE_RAINSENSOR: + self._attr_state = self._controller.get_rain_sensor_state() + elif self.entity_description.key == SENSOR_TYPE_RAINDELAY: + self._attr_state = self._controller.get_rain_delay() diff --git a/homeassistant/components/rainbird/services.yaml b/homeassistant/components/rainbird/services.yaml index e1fa1879549..3d5f55dba14 100644 --- a/homeassistant/components/rainbird/services.yaml +++ b/homeassistant/components/rainbird/services.yaml @@ -19,3 +19,16 @@ start_irrigation: min: 1 max: 1440 unit_of_measurement: "minutes" +set_rain_delay: + name: Set rain delay + description: Set how long automatic irrigation is turned off. + fields: + duration: + name: Duration + description: Duration for this system to be turned off. + required: true + selector: + number: + min: 0 + max: 14 + unit_of_measurement: "days" diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 7acb9740616..df83f054275 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -11,6 +11,7 @@ from . import CONF_ZONES, DATA_RAINBIRD, DOMAIN, RAINBIRD_CONTROLLER ATTR_DURATION = "duration" SERVICE_START_IRRIGATION = "start_irrigation" +SERVICE_SET_RAIN_DELAY = "set_rain_delay" SERVICE_SCHEMA_IRRIGATION = vol.Schema( { @@ -19,6 +20,12 @@ SERVICE_SCHEMA_IRRIGATION = vol.Schema( } ) +SERVICE_SCHEMA_RAIN_DELAY = vol.Schema( + { + vol.Required(ATTR_DURATION): cv.positive_float, + } +) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Rain Bird switches over a Rain Bird controller.""" @@ -64,6 +71,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): schema=SERVICE_SCHEMA_IRRIGATION, ) + def set_rain_delay(service): + duration = service.data[ATTR_DURATION] + + controller.set_rain_delay(duration) + + hass.services.register( + DOMAIN, + SERVICE_SET_RAIN_DELAY, + set_rain_delay, + schema=SERVICE_SCHEMA_RAIN_DELAY, + ) + class RainBirdSwitch(SwitchEntity): """Representation of a Rain Bird switch.""" diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index d333f9437f1..53e94d2070e 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -1,5 +1,8 @@ """Support for the Rainforest Eagle-200 energy monitor.""" -from datetime import timedelta +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta import logging from eagle200_reader import EagleReader @@ -7,14 +10,19 @@ from requests.exceptions import ConnectionError as ConnectError, HTTPError, Time from uEagle import Eagle as LegacyReader import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + DEVICE_CLASS_ENERGY, + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.const import ( CONF_IP_ADDRESS, DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ) import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.util import Throttle, dt CONF_CLOUD_ID = "cloud_id" CONF_INSTALL_CODE = "install_code" @@ -24,19 +32,43 @@ _LOGGER = logging.getLogger(__name__) MIN_SCAN_INTERVAL = timedelta(seconds=30) + +@dataclass +class SensorType: + """Rainforest sensor type.""" + + name: str + unit_of_measurement: str + device_class: str | None = None + state_class: str | None = None + last_reset: datetime | None = None + + SENSORS = { - "instantanous_demand": ("Eagle-200 Meter Power Demand", POWER_KILO_WATT), - "summation_delivered": ( - "Eagle-200 Total Meter Energy Delivered", - ENERGY_KILO_WATT_HOUR, + "instantanous_demand": SensorType( + name="Eagle-200 Meter Power Demand", + unit_of_measurement=POWER_KILO_WATT, + device_class=DEVICE_CLASS_POWER, ), - "summation_received": ( - "Eagle-200 Total Meter Energy Received", - ENERGY_KILO_WATT_HOUR, + "summation_delivered": SensorType( + name="Eagle-200 Total Meter Energy Delivered", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), ), - "summation_total": ( - "Eagle-200 Net Meter Energy (Delivered minus Received)", - ENERGY_KILO_WATT_HOUR, + "summation_received": SensorType( + name="Eagle-200 Total Meter Energy Received", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + last_reset=dt.utc_from_timestamp(0), + ), + "summation_total": SensorType( + name="Eagle-200 Net Meter Energy (Delivered minus Received)", + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, ), } @@ -86,56 +118,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): eagle_data = EagleData(eagle_reader) eagle_data.update() - monitored_conditions = list(SENSORS) - sensors = [] - for condition in monitored_conditions: - sensors.append( - EagleSensor( - eagle_data, condition, SENSORS[condition][0], SENSORS[condition][1] - ) - ) - add_entities(sensors) + add_entities(EagleSensor(eagle_data, condition) for condition in SENSORS) class EagleSensor(SensorEntity): """Implementation of the Rainforest Eagle-200 sensor.""" - def __init__(self, eagle_data, sensor_type, name, unit): + def __init__(self, eagle_data, sensor_type): """Initialize the sensor.""" self.eagle_data = eagle_data self._type = sensor_type - self._name = name - self._unit_of_measurement = unit - self._state = None - - @property - def device_class(self): - """Return the power device class for the instantanous_demand sensor.""" - if self._type == "instantanous_demand": - return DEVICE_CLASS_POWER - - return None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit_of_measurement + sensor_info = SENSORS[sensor_type] + self._attr_name = sensor_info.name + self._attr_unit_of_measurement = sensor_info.unit_of_measurement + self._attr_device_class = sensor_info.device_class + self._attr_state_class = sensor_info.state_class + self._attr_last_reset = sensor_info.last_reset def update(self): """Get the energy information from the Rainforest Eagle.""" self.eagle_data.update() - self._state = self.eagle_data.get_state(self._type) + self._attr_state = self.eagle_data.get_state(self._type) class EagleData: diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 09af357617d..8d3f9444f08 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -1,7 +1,10 @@ """Support for RainMachine devices.""" +from __future__ import annotations + import asyncio from datetime import timedelta from functools import partial +from typing import Any from regenmaschine import Client from regenmaschine.controller import Controller @@ -19,7 +22,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_validation as cv import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -94,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.entry_id ] = get_client_controller(client) - entry_updates = {} + entry_updates: dict[str, Any] = {} if not entry.unique_id or is_ip_address(entry.unique_id): # If the config entry doesn't already have a unique ID, set one: entry_updates["unique_id"] = controller.mac @@ -112,31 +114,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update(api_category: str) -> dict: """Update the appropriate API data based on a category.""" + data: dict = {} + try: if api_category == DATA_PROGRAMS: - return await controller.programs.all(include_inactive=True) - - if api_category == DATA_PROVISION_SETTINGS: - return await controller.provisioning.settings() - - if api_category == DATA_RESTRICTIONS_CURRENT: - return await controller.restrictions.current() - - if api_category == DATA_RESTRICTIONS_UNIVERSAL: - return await controller.restrictions.universal() - - return await controller.zones.all(details=True, include_inactive=True) + data = await controller.programs.all(include_inactive=True) + elif api_category == DATA_PROVISION_SETTINGS: + data = await controller.provisioning.settings() + elif api_category == DATA_RESTRICTIONS_CURRENT: + data = await controller.restrictions.current() + elif api_category == DATA_RESTRICTIONS_UNIVERSAL: + data = await controller.restrictions.universal() + else: + data = await controller.zones.all(details=True, include_inactive=True) except RainMachineError as err: raise UpdateFailed(err) from err + return data + controller_init_tasks = [] - for api_category in [ + for api_category in ( DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, - ]: + ): coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][ api_category ] = DataUpdateCoordinator( @@ -174,56 +177,40 @@ class RainMachineEntity(CoordinatorEntity): """Define a generic RainMachine entity.""" def __init__( - self, coordinator: DataUpdateCoordinator, controller: Controller + self, + coordinator: DataUpdateCoordinator, + controller: Controller, + entity_type: str, ) -> None: """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} - self._controller = controller - self._device_class = None + + self._attr_device_info = { + "identifiers": {(DOMAIN, controller.mac)}, + "connections": {(dr.CONNECTION_NETWORK_MAC, controller.mac)}, + "name": controller.name, + "manufacturer": "RainMachine", + "model": ( + f"Version {controller.hardware_version} " + f"(API: {controller.api_version})" + ), + "sw_version": controller.software_version, + } + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} # The colons are removed from the device MAC simply because that value # (unnecessarily) makes up the existing unique ID formula and we want to avoid # a breaking change: - self._unique_id = controller.mac.replace(":", "") - self._name = None - - @property - def device_class(self) -> str: - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return device registry information for this entity.""" - return { - "identifiers": {(DOMAIN, self._controller.mac)}, - "connections": {(dr.CONNECTION_NETWORK_MAC, self._controller.mac)}, - "name": self._controller.name, - "manufacturer": "RainMachine", - "model": ( - f"Version {self._controller.hardware_version} " - f"(API: {self._controller.api_version})" - ), - "sw_version": self._controller.software_version, - } - - @property - def extra_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attrs - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name + self._attr_unique_id = f"{controller.mac.replace(':', '')}_{entity_type}" + self._controller = controller + self._entity_type = entity_type @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" self.update_from_latest_data() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() self.update_from_latest_data() diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 171fd26b910..b666d9ed150 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -125,32 +125,11 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorEntity): enabled_by_default: bool, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, controller) - self._enabled_by_default = enabled_by_default - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._state = None + super().__init__(coordinator, controller, sensor_type) - @property - def entity_registry_enabled_default(self) -> bool: - """Determine whether an entity is enabled by default.""" - return self._enabled_by_default - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def is_on(self) -> bool: - """Return the status of the sensor.""" - return self._state - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._unique_id}_{self._sensor_type}" + self._attr_entity_registry_enabled_default = enabled_by_default + self._attr_icon = icon + self._attr_name = name class CurrentRestrictionsBinarySensor(RainMachineBinarySensor): @@ -159,18 +138,18 @@ class CurrentRestrictionsBinarySensor(RainMachineBinarySensor): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._sensor_type == TYPE_FREEZE: - self._state = self.coordinator.data["freeze"] - elif self._sensor_type == TYPE_HOURLY: - self._state = self.coordinator.data["hourly"] - elif self._sensor_type == TYPE_MONTH: - self._state = self.coordinator.data["month"] - elif self._sensor_type == TYPE_RAINDELAY: - self._state = self.coordinator.data["rainDelay"] - elif self._sensor_type == TYPE_RAINSENSOR: - self._state = self.coordinator.data["rainSensor"] - elif self._sensor_type == TYPE_WEEKDAY: - self._state = self.coordinator.data["weekDay"] + if self._entity_type == TYPE_FREEZE: + self._attr_is_on = self.coordinator.data["freeze"] + elif self._entity_type == TYPE_HOURLY: + self._attr_is_on = self.coordinator.data["hourly"] + elif self._entity_type == TYPE_MONTH: + self._attr_is_on = self.coordinator.data["month"] + elif self._entity_type == TYPE_RAINDELAY: + self._attr_is_on = self.coordinator.data["rainDelay"] + elif self._entity_type == TYPE_RAINSENSOR: + self._attr_is_on = self.coordinator.data["rainSensor"] + elif self._entity_type == TYPE_WEEKDAY: + self._attr_is_on = self.coordinator.data["weekDay"] class ProvisionSettingsBinarySensor(RainMachineBinarySensor): @@ -179,8 +158,8 @@ class ProvisionSettingsBinarySensor(RainMachineBinarySensor): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._sensor_type == TYPE_FLOW_SENSOR: - self._state = self.coordinator.data["system"].get("useFlowSensor") + if self._entity_type == TYPE_FLOW_SENSOR: + self._attr_is_on = self.coordinator.data["system"].get("useFlowSensor") class UniversalRestrictionsBinarySensor(RainMachineBinarySensor): @@ -189,7 +168,7 @@ class UniversalRestrictionsBinarySensor(RainMachineBinarySensor): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._sensor_type == TYPE_FREEZE_PROTECTION: - self._state = self.coordinator.data["freezeProtectEnabled"] - elif self._sensor_type == TYPE_HOT_DAYS: - self._state = self.coordinator.data["hotDaysExtraWatering"] + if self._entity_type == TYPE_FREEZE_PROTECTION: + self._attr_is_on = self.coordinator.data["freezeProtectEnabled"] + elif self._entity_type == TYPE_HOT_DAYS: + self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"] diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 55ff68c5ea0..c392ad1f8ce 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,31 +1,33 @@ """Config flow to configure the RainMachine component.""" +from __future__ import annotations + +from typing import Any + from regenmaschine import Client +from regenmaschine.controller import Controller from regenmaschine.errors import RainMachineError import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_ZONE_RUN_TIME, DEFAULT_PORT, DEFAULT_ZONE_RUN, DOMAIN -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_IP_ADDRESS): str, - vol.Required(CONF_PASSWORD): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - } -) - -def get_client_controller(client): +@callback +def get_client_controller(client: Client) -> Controller: """Return the first local controller.""" return next(iter(client.controllers.values())) -async def async_get_controller(hass, ip_address, password, port, ssl): +async def async_get_controller( + hass: HomeAssistant, ip_address: str, password: str, port: int, ssl: bool +) -> Controller | None: """Auth and fetch the mac address from the controller.""" websession = aiohttp_client.async_get_clientsession(hass) client = Client(session=websession) @@ -42,21 +44,23 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): - """Initialize config flow.""" - self.discovered_ip_address = None + discovered_ip_address: str | None = None @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> RainMachineOptionsFlowHandler: """Define the config flow to handle options.""" return RainMachineOptionsFlowHandler(config_entry) - async def async_step_homekit(self, discovery_info): + async def async_step_homekit(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a flow initialized by homekit discovery.""" return await self.async_step_zeroconf(discovery_info) - async def async_step_zeroconf(self, discovery_info: DiscoveryInfoType): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle discovery via zeroconf.""" ip_address = discovery_info["host"] @@ -86,7 +90,7 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() @callback - def _async_generate_schema(self): + def _async_generate_schema(self) -> vol.Schema: """Generate schema.""" return vol.Schema( { @@ -96,7 +100,9 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" errors = {} if user_input: @@ -134,6 +140,7 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if self.discovered_ip_address: self.context["title_placeholders"] = {"ip": self.discovered_ip_address} + return self.async_show_form( step_id="user", data_schema=self._async_generate_schema(), errors=errors ) @@ -142,11 +149,13 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class RainMachineOptionsFlowHandler(config_entries.OptionsFlow): """Handle a RainMachine options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index b6021d02c39..03fedcf8c57 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==3.0.0"], + "requirements": ["regenmaschine==3.1.5"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 2ebd9d0fdb4..808c6a06bc2 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -5,7 +5,11 @@ from regenmaschine.controller import Controller from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS, VOLUME_CUBIC_METERS +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + VOLUME_CUBIC_METERS, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -62,7 +66,7 @@ SENSORS = { "Freeze Protect Temperature", "mdi:thermometer", TEMP_CELSIUS, - "temperature", + DEVICE_CLASS_TEMPERATURE, True, DATA_RESTRICTIONS_UNIVERSAL, ), @@ -124,39 +128,13 @@ class RainMachineSensor(RainMachineEntity, SensorEntity): enabled_by_default: bool, ) -> None: """Initialize.""" - super().__init__(coordinator, controller) - self._device_class = device_class - self._enabled_by_default = enabled_by_default - self._icon = icon - self._name = name - self._sensor_type = sensor_type - self._state = None - self._unit = unit + super().__init__(coordinator, controller, sensor_type) - @property - def entity_registry_enabled_default(self) -> bool: - """Determine whether an entity is enabled by default.""" - return self._enabled_by_default - - @property - def icon(self) -> str: - """Return the icon.""" - return self._icon - - @property - def state(self) -> str: - """Return the name of the entity.""" - return self._state - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._unique_id}_{self._sensor_type}" - - @property - def unit_of_measurement(self) -> str: - """Return the unit the value is expressed in.""" - return self._unit + self._attr_device_class = device_class + self._attr_entity_registry_enabled_default = enabled_by_default + self._attr_icon = icon + self._attr_name = name + self._attr_unit_of_measurement = unit class ProvisionSettingsSensor(RainMachineSensor): @@ -165,24 +143,26 @@ class ProvisionSettingsSensor(RainMachineSensor): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3: - self._state = self.coordinator.data["system"].get( + if self._entity_type == TYPE_FLOW_SENSOR_CLICK_M3: + self._attr_state = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) - elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: + elif self._entity_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS: clicks = self.coordinator.data["system"].get("flowSensorWateringClicks") clicks_per_m3 = self.coordinator.data["system"].get( "flowSensorClicksPerCubicMeter" ) if clicks and clicks_per_m3: - self._state = (clicks * 1000) / clicks_per_m3 + self._attr_state = (clicks * 1000) / clicks_per_m3 else: - self._state = None - elif self._sensor_type == TYPE_FLOW_SENSOR_START_INDEX: - self._state = self.coordinator.data["system"].get("flowSensorStartIndex") - elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: - self._state = self.coordinator.data["system"].get( + self._attr_state = None + elif self._entity_type == TYPE_FLOW_SENSOR_START_INDEX: + self._attr_state = self.coordinator.data["system"].get( + "flowSensorStartIndex" + ) + elif self._entity_type == TYPE_FLOW_SENSOR_WATERING_CLICKS: + self._attr_state = self.coordinator.data["system"].get( "flowSensorWateringClicks" ) @@ -193,5 +173,5 @@ class UniversalRestrictionsSensor(RainMachineSensor): @callback def update_from_latest_data(self) -> None: """Update the state.""" - if self._sensor_type == TYPE_FREEZE_TEMP: - self._state = self.coordinator.data["freezeProtectTemp"] + if self._entity_type == TYPE_FREEZE_TEMP: + self._attr_state = self.coordinator.data["freezeProtectTemp"] diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index b0544b1adbe..9554b22d783 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Coroutine from datetime import datetime +from typing import Any from regenmaschine.controller import Controller from regenmaschine.errors import RequestError @@ -53,6 +54,8 @@ CONF_PROGRAM_ID = "program_id" CONF_SECONDS = "seconds" CONF_ZONE_ID = "zone_id" +DEFAULT_ICON = "mdi:water" + DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] RUN_STATUS_MAP = {0: "Not Running", 1: "Running", 2: "Queued"} @@ -121,7 +124,7 @@ async def async_setup_entry( alter_program_schema = {vol.Required(CONF_PROGRAM_ID): cv.positive_int} alter_zone_schema = {vol.Required(CONF_ZONE_ID): cv.positive_int} - for service_name, schema, method in [ + for service_name, schema, method in ( ("disable_program", alter_program_schema, "async_disable_program"), ("disable_zone", alter_zone_schema, "async_disable_zone"), ("enable_program", alter_program_schema, "async_enable_program"), @@ -154,7 +157,7 @@ async def async_setup_entry( ), ("stop_zone", {vol.Required(CONF_ZONE_ID): cv.positive_int}, "async_stop_zone"), ("unpause_watering", {}, "async_unpause_watering"), - ]: + ): platform.async_register_entity_service(service_name, schema, method) controller = hass.data[DOMAIN][DATA_CONTROLLER][entry.entry_id] @@ -163,7 +166,8 @@ async def async_setup_entry( ] zones_coordinator = hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id][DATA_ZONES] - entities = [] + entities: list[RainMachineProgram | RainMachineZone] = [] + for uid, program in programs_coordinator.data.items(): entities.append( RainMachineProgram( @@ -181,6 +185,8 @@ async def async_setup_entry( class RainMachineSwitch(RainMachineEntity, SwitchEntity): """A class to represent a generic RainMachine switch.""" + _attr_icon = DEFAULT_ICON + def __init__( self, coordinator: DataUpdateCoordinator, @@ -190,34 +196,24 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): entry: ConfigEntry, ) -> None: """Initialize a generic RainMachine switch.""" - super().__init__(coordinator, controller) + super().__init__(coordinator, controller, type(self).__name__) + + self._attr_is_on = False + self._attr_name = name self._data = coordinator.data[uid] self._entry = entry self._is_active = True - self._is_on = False - self._name = name - self._switch_type = type(self).__name__ self._uid = uid @property def available(self) -> bool: """Return True if entity is available.""" - return self._is_active and self.coordinator.last_update_success - - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:water" - - @property - def is_on(self) -> bool: - """Return whether the program is running.""" - return self._is_on + return super().available and self._is_active @property def unique_id(self) -> str: """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._unique_id}_{self._switch_type}_{self._uid}" + return f"{super().unique_id}_{self._uid}" async def _async_run_switch_coroutine(self, api_coro: Coroutine) -> None: """Run a coroutine to toggle the switch.""" @@ -226,7 +222,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): except RequestError as err: LOGGER.error( 'Error while toggling %s "%s": %s', - self._switch_type, + self._entity_type, self.unique_id, err, ) @@ -235,7 +231,7 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): if resp["statusCode"] != 0: LOGGER.error( 'Error while toggling %s "%s": %s', - self._switch_type, + self._entity_type, self.unique_id, resp["message"], ) @@ -247,57 +243,57 @@ class RainMachineSwitch(RainMachineEntity, SwitchEntity): async_update_programs_and_zones(self.hass, self._entry) ) - async def async_disable_program(self, *, program_id): + async def async_disable_program(self, *, program_id: int) -> None: """Disable a program.""" await self._controller.programs.disable(program_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_disable_zone(self, *, zone_id): + async def async_disable_zone(self, *, zone_id: int) -> None: """Disable a zone.""" await self._controller.zones.disable(zone_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_enable_program(self, *, program_id): + async def async_enable_program(self, *, program_id: int) -> None: """Enable a program.""" await self._controller.programs.enable(program_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_enable_zone(self, *, zone_id): + async def async_enable_zone(self, *, zone_id: int) -> None: """Enable a zone.""" await self._controller.zones.enable(zone_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_pause_watering(self, *, seconds): + async def async_pause_watering(self, *, seconds: int) -> None: """Pause watering for a set number of seconds.""" await self._controller.watering.pause_all(seconds) await async_update_programs_and_zones(self.hass, self._entry) - async def async_start_program(self, *, program_id): + async def async_start_program(self, *, program_id: int) -> None: """Start a particular program.""" await self._controller.programs.start(program_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_start_zone(self, *, zone_id, zone_run_time): + async def async_start_zone(self, *, zone_id: int, zone_run_time: int) -> None: """Start a particular zone for a certain amount of time.""" await self._controller.zones.start(zone_id, zone_run_time) await async_update_programs_and_zones(self.hass, self._entry) - async def async_stop_all(self): + async def async_stop_all(self) -> None: """Stop all watering.""" await self._controller.watering.stop_all() await async_update_programs_and_zones(self.hass, self._entry) - async def async_stop_program(self, *, program_id): + async def async_stop_program(self, *, program_id: int) -> None: """Stop a program.""" await self._controller.programs.stop(program_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_stop_zone(self, *, zone_id): + async def async_stop_zone(self, *, zone_id: int) -> None: """Stop a zone.""" await self._controller.zones.stop(zone_id) await async_update_programs_and_zones(self.hass, self._entry) - async def async_unpause_watering(self): + async def async_unpause_watering(self) -> None: """Unpause watering.""" await self._controller.watering.unpause_all() await async_update_programs_and_zones(self.hass, self._entry) @@ -317,13 +313,13 @@ class RainMachineProgram(RainMachineSwitch): """Return a list of active zones associated with this program.""" return [z for z in self._data["wateringTimes"] if z["active"]] - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: """Turn the program off.""" await self._async_run_switch_coroutine( self._controller.programs.stop(self._uid) ) - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: """Turn the program on.""" await self._async_run_switch_coroutine( self._controller.programs.start(self._uid) @@ -334,17 +330,16 @@ class RainMachineProgram(RainMachineSwitch): """Update the state.""" super().update_from_latest_data() - self._is_on = bool(self._data["status"]) + self._attr_is_on = bool(self._data["status"]) + next_run: str | None = None if self._data.get("nextRun") is not None: next_run = datetime.strptime( f"{self._data['nextRun']} {self._data['startTime']}", "%Y-%m-%d %H:%M", ).isoformat() - else: - next_run = None - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_ID: self._uid, ATTR_NEXT_RUN: next_run, @@ -358,11 +353,11 @@ class RainMachineProgram(RainMachineSwitch): class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: dict[str, Any]) -> None: """Turn the zone off.""" await self._async_run_switch_coroutine(self._controller.zones.stop(self._uid)) - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: dict[str, Any]) -> None: """Turn the zone on.""" await self._async_run_switch_coroutine( self._controller.zones.start( @@ -376,9 +371,9 @@ class RainMachineZone(RainMachineSwitch): """Update the state.""" super().update_from_latest_data() - self._is_on = bool(self._data["state"]) + self._attr_is_on = bool(self._data["state"]) - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_STATUS: RUN_STATUS_MAP[self._data["state"]], ATTR_AREA: self._data.get("waterSense").get("area"), diff --git a/homeassistant/components/rainmachine/translations/id.json b/homeassistant/components/rainmachine/translations/id.json index 482ffb75278..bcbb9126b2a 100644 --- a/homeassistant/components/rainmachine/translations/id.json +++ b/homeassistant/components/rainmachine/translations/id.json @@ -6,6 +6,7 @@ "error": { "invalid_auth": "Autentikasi tidak valid" }, + "flow_title": "{ip}", "step": { "user": { "data": { diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index f061532c3d1..f6a5398d901 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -13,22 +13,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DATA_COORDINATOR, DOMAIN, LOGGER -DATA_LISTENER = "listener" - DEFAULT_NAME = "recollect_waste" DEFAULT_UPDATE_INTERVAL = timedelta(days=1) PLATFORMS = ["sensor"] -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - """Set up the RainMachine component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}, DATA_LISTENER: {}} - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up RainMachine as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}}) + session = aiohttp_client.async_get_clientsession(hass) client = Client( entry.data[CONF_PLACE_ID], entry.data[CONF_SERVICE_ID], session=session @@ -59,14 +53,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - hass.data[DOMAIN][DATA_LISTENER][entry.entry_id] = entry.add_update_listener( - async_reload_entry - ) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(entry.entry_id) @@ -76,7 +68,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN][DATA_COORDINATOR].pop(entry.entry_id) - cancel_listener = hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id) - cancel_listener() return unload_ok diff --git a/homeassistant/components/recollect_waste/config_flow.py b/homeassistant/components/recollect_waste/config_flow.py index 9919c2653a8..92f94a314ee 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -1,6 +1,8 @@ """Config flow for ReCollect Waste integration.""" from __future__ import annotations +from typing import Any + from aiorecollect.client import Client from aiorecollect.errors import RecollectError import voluptuous as vol @@ -8,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_FRIENDLY_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_PLACE_ID, CONF_SERVICE_ID, DOMAIN, LOGGER @@ -30,11 +33,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return RecollectWasteOptionsFlowHandler(config_entry) - async def async_step_import(self, import_config: dict = None) -> dict: + async def async_step_import( + self, import_config: dict[str, Any] | None = None + ) -> FlowResult: """Handle configuration via YAML import.""" return await self.async_step_user(import_config) - async def async_step_user(self, user_input: dict = None) -> dict: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle configuration via the UI.""" if user_input is None: return self.async_show_form( @@ -77,7 +84,9 @@ class RecollectWasteOptionsFlowHandler(config_entries.OptionsFlow): """Initialize.""" self._entry = entry - async def async_step_init(self, user_input: dict | None = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index 550612fbea2..258d74915f7 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -3,7 +3,7 @@ "name": "ReCollect Waste", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/recollect_waste", - "requirements": ["aiorecollect==1.0.5"], + "requirements": ["aiorecollect==1.0.7"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py index 68c810bc90d..beb7c182351 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -55,10 +56,10 @@ def async_get_pickup_type_names( async def async_setup_platform( hass: HomeAssistant, - config: dict, + config: ConfigType, async_add_entities: AddEntitiesCallback, - discovery_info: dict = None, -): + discovery_info: DiscoveryInfoType | None = None, +) -> None: """Import Recollect Waste configuration from YAML.""" LOGGER.warning( "Loading ReCollect Waste via platform setup is deprecated; " @@ -84,37 +85,18 @@ async def async_setup_entry( class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): """ReCollect Waste Sensor.""" + _attr_device_class = DEVICE_CLASS_TIMESTAMP + def __init__(self, coordinator: DataUpdateCoordinator, entry: ConfigEntry) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_name = DEFAULT_NAME + self._attr_unique_id = ( + f"{entry.data[CONF_PLACE_ID]}{entry.data[CONF_SERVICE_ID]}" + ) self._entry = entry - self._state = None - - @property - def device_class(self) -> dict: - """Return the device class.""" - return DEVICE_CLASS_TIMESTAMP - - @property - def extra_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attributes - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return DEFAULT_NAME - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self._state - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._entry.data[CONF_PLACE_ID]}{self._entry.data[CONF_SERVICE_ID]}" @callback def _handle_coordinator_update(self) -> None: @@ -133,8 +115,7 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): pickup_event = self.coordinator.data[0] next_pickup_event = self.coordinator.data[1] - self._state = as_utc(pickup_event.date).isoformat() - self._attributes.update( + self._attr_extra_state_attributes.update( { ATTR_PICKUP_TYPES: async_get_pickup_type_names( self._entry, pickup_event.pickup_types @@ -146,3 +127,4 @@ class ReCollectWasteSensor(CoordinatorEntity, SensorEntity): ATTR_NEXT_PICKUP_DATE: as_utc(next_pickup_event.date).isoformat(), } ) + self._attr_state = as_utc(pickup_event.date).isoformat() diff --git a/homeassistant/components/recollect_waste/translations/de.json b/homeassistant/components/recollect_waste/translations/de.json index fdeab56f54e..750f84dd210 100644 --- a/homeassistant/components/recollect_waste/translations/de.json +++ b/homeassistant/components/recollect_waste/translations/de.json @@ -19,7 +19,7 @@ "step": { "init": { "data": { - "friendly_name": "Verwenden Sie freundliche Namen f\u00fcr Pickup-Typen (wenn m\u00f6glich)" + "friendly_name": "Verwende freundliche Namen f\u00fcr Pickup-Typen (wenn m\u00f6glich)" }, "title": "Recollect Waste konfigurieren" } diff --git a/homeassistant/components/recollect_waste/translations/hu.json b/homeassistant/components/recollect_waste/translations/hu.json index 3222f50be02..4c570b8d9de 100644 --- a/homeassistant/components/recollect_waste/translations/hu.json +++ b/homeassistant/components/recollect_waste/translations/hu.json @@ -18,6 +18,9 @@ "options": { "step": { "init": { + "data": { + "friendly_name": "Bar\u00e1ts\u00e1gos nevek haszn\u00e1lata a felv\u00e9teli t\u00edpusok eset\u00e9ben (ha lehets\u00e9ges)" + }, "title": "Recollect Waste konfigur\u00e1l\u00e1sa" } } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c16d7a2d198..e9c12e5f88a 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -324,7 +324,7 @@ class PerodicCleanupTask: class StatisticsTask(NamedTuple): """An object to insert into the recorder queue to run a statistics task.""" - start: datetime.datetime + start: datetime class WaitTask: @@ -358,7 +358,7 @@ class Recorder(threading.Thread): self.db_url = uri self.db_max_retries = db_max_retries self.db_retry_wait = db_retry_wait - self.async_db_ready = asyncio.Future() + self.async_db_ready: asyncio.Future = asyncio.Future() self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() self.engine: Any = None @@ -370,8 +370,8 @@ class Recorder(threading.Thread): self._timechanges_seen = 0 self._commits_without_expire = 0 self._keepalive_count = 0 - self._old_states = {} - self._pending_expunge = [] + self._old_states: dict[str, States] = {} + self._pending_expunge: list[States] = [] self.event_session = None self.get_session = None self._completed_first_database_setup = None diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 2ed676bfdb9..06391f2864d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -304,7 +304,7 @@ def _update_states_table_with_foreign_key_options(connection, engine): states_key_constraints = Base.metadata.tables[TABLE_STATES].foreign_key_constraints old_states_table = Table( # noqa: F841 pylint: disable=unused-variable - TABLE_STATES, MetaData(), *[alter["old_fk"] for alter in alters] + TABLE_STATES, MetaData(), *(alter["old_fk"] for alter in alters) ) for alter in alters: diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index c77d824c64f..929115bdf25 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,6 +1,10 @@ """Models for SQLAlchemy.""" +from __future__ import annotations + +from datetime import datetime import json import logging +from typing import TypedDict from sqlalchemy import ( Boolean, @@ -97,7 +101,8 @@ class Events(Base): # type: ignore """Create an event database object from a native event.""" return Events( event_type=event.event_type, - event_data=event_data or json.dumps(event.data, cls=JSONEncoder), + event_data=event_data + or json.dumps(event.data, cls=JSONEncoder, separators=(",", ":")), origin=str(event.origin.value), time_fired=event.time_fired, context_id=event.context.id, @@ -180,7 +185,9 @@ class States(Base): # type: ignore else: dbstate.domain = state.domain dbstate.state = state.state - dbstate.attributes = json.dumps(dict(state.attributes), cls=JSONEncoder) + dbstate.attributes = json.dumps( + dict(state.attributes), cls=JSONEncoder, separators=(",", ":") + ) dbstate.last_changed = state.last_changed dbstate.last_updated = state.last_updated @@ -206,6 +213,17 @@ class States(Base): # type: ignore return None +class StatisticData(TypedDict, total=False): + """Statistic data class.""" + + mean: float + min: float + max: float + last_reset: datetime | None + state: float + sum: float + + class Statistics(Base): # type: ignore """Statistics.""" @@ -230,7 +248,7 @@ class Statistics(Base): # type: ignore sum = Column(Float()) @staticmethod - def from_stats(metadata_id, start, stats): + def from_stats(metadata_id: str, start: datetime, stats: StatisticData): """Create object from a statistics.""" return Statistics( metadata_id=metadata_id, @@ -239,6 +257,14 @@ class Statistics(Base): # type: ignore ) +class StatisticMetaData(TypedDict, total=False): + """Statistic meta data class.""" + + unit_of_measurement: str | None + has_mean: bool + has_sum: bool + + class StatisticsMeta(Base): # type: ignore """Statistics meta data.""" @@ -251,7 +277,13 @@ class StatisticsMeta(Base): # type: ignore has_sum = Column(Boolean) @staticmethod - def from_meta(source, statistic_id, unit_of_measurement, has_mean, has_sum): + def from_meta( + source: str, + statistic_id: str, + unit_of_measurement: str | None, + has_mean: bool, + has_sum: bool, + ) -> StatisticsMeta: """Create object from meta data.""" return StatisticsMeta( source=source, @@ -340,7 +372,7 @@ def process_timestamp(ts): return dt_util.as_utc(ts) -def process_timestamp_to_utc_isoformat(ts): +def process_timestamp_to_utc_isoformat(ts: datetime | None) -> str | None: """Process a timestamp into UTC isotime.""" if ts is None: return None diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 2ef49df7ded..f3b0b27df39 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -5,18 +5,27 @@ from collections import defaultdict from datetime import datetime, timedelta from itertools import groupby import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable from sqlalchemy import bindparam from sqlalchemy.ext import baked +from sqlalchemy.orm.scoping import scoped_session from homeassistant.const import PRESSURE_PA, TEMP_CELSIUS +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util +from homeassistant.util.unit_system import UnitSystem from .const import DOMAIN -from .models import Statistics, StatisticsMeta, process_timestamp_to_utc_isoformat +from .models import ( + StatisticMetaData, + Statistics, + StatisticsMeta, + process_timestamp_to_utc_isoformat, +) from .util import execute, retryable_database_job, session_scope if TYPE_CHECKING: @@ -60,20 +69,47 @@ UNIT_CONVERSIONS = { _LOGGER = logging.getLogger(__name__) -def async_setup(hass): +def async_setup(hass: HomeAssistant) -> None: """Set up the history hooks.""" hass.data[STATISTICS_BAKERY] = baked.bakery() hass.data[STATISTICS_META_BAKERY] = baked.bakery() + def entity_id_changed(event: Event) -> None: + """Handle entity_id changed.""" + old_entity_id = event.data["old_entity_id"] + entity_id = event.data["entity_id"] + with session_scope(hass=hass) as session: + session.query(StatisticsMeta).filter( + StatisticsMeta.statistic_id == old_entity_id + and StatisticsMeta.source == DOMAIN + ).update({StatisticsMeta.statistic_id: entity_id}) -def get_start_time() -> datetime.datetime: + @callback + def entity_registry_changed_filter(event: Event) -> bool: + """Handle entity_id changed filter.""" + if event.data["action"] != "update" or "old_entity_id" not in event.data: + return False + + return True + + if hass.is_running: + hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, + entity_id_changed, + event_filter=entity_registry_changed_filter, + ) + + +def get_start_time() -> datetime: """Return start time.""" last_hour = dt_util.utcnow() - timedelta(hours=1) start = last_hour.replace(minute=0, second=0, microsecond=0) return start -def _get_metadata_ids(hass, session, statistic_ids): +def _get_metadata_ids( + hass: HomeAssistant, session: scoped_session, statistic_ids: list[str] +) -> list[str]: """Resolve metadata_id for a list of statistic_ids.""" baked_query = hass.data[STATISTICS_META_BAKERY]( lambda session: session.query(*QUERY_STATISTIC_META) @@ -83,10 +119,15 @@ def _get_metadata_ids(hass, session, statistic_ids): ) result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - return [id for id, _, _ in result] + return [id for id, _, _ in result] if result else [] -def _get_or_add_metadata_id(hass, session, statistic_id, metadata): +def _get_or_add_metadata_id( + hass: HomeAssistant, + session: scoped_session, + statistic_id: str, + metadata: StatisticMetaData, +) -> str: """Get metadata_id for a statistic_id, add if it doesn't exist.""" metadata_id = _get_metadata_ids(hass, session, [statistic_id]) if not metadata_id: @@ -101,7 +142,7 @@ def _get_or_add_metadata_id(hass, session, statistic_id, metadata): @retryable_database_job("statistics") -def compile_statistics(instance: Recorder, start: datetime.datetime) -> bool: +def compile_statistics(instance: Recorder, start: datetime) -> bool: """Compile statistics.""" start = dt_util.as_utc(start) end = start + timedelta(hours=1) @@ -126,10 +167,15 @@ def compile_statistics(instance: Recorder, start: datetime.datetime) -> bool: return True -def _get_metadata(hass, session, statistic_ids, statistic_type): +def _get_metadata( + hass: HomeAssistant, + session: scoped_session, + statistic_ids: list[str] | None, + statistic_type: str | None, +) -> dict[str, dict[str, str]]: """Fetch meta data.""" - def _meta(metas, wanted_metadata_id): + def _meta(metas: list, wanted_metadata_id: str) -> dict[str, str] | None: meta = None for metadata_id, statistic_id, unit in metas: if metadata_id == wanted_metadata_id: @@ -145,15 +191,24 @@ def _get_metadata(hass, session, statistic_ids, statistic_type): ) if statistic_type == "mean": baked_query += lambda q: q.filter(StatisticsMeta.has_mean.isnot(False)) - if statistic_type == "sum": + elif statistic_type == "sum": baked_query += lambda q: q.filter(StatisticsMeta.has_sum.isnot(False)) + elif statistic_type is not None: + return {} result = execute(baked_query(session).params(statistic_ids=statistic_ids)) + if not result: + return {} metadata_ids = [metadata[0] for metadata in result] - return {id: _meta(result, id) for id in metadata_ids} + metadata = {} + for _id in metadata_ids: + meta = _meta(result, _id) + if meta: + metadata[_id] = meta + return metadata -def _configured_unit(unit: str, units) -> str: +def _configured_unit(unit: str, units: UnitSystem) -> str: """Return the pressure and temperature units configured by the user.""" if unit == PRESSURE_PA: return units.pressure_unit @@ -162,9 +217,12 @@ def _configured_unit(unit: str, units) -> str: return unit -def list_statistic_ids(hass, statistic_type=None): +def list_statistic_ids( + hass: HomeAssistant, statistic_type: str | None = None +) -> list[dict[str, str] | None]: """Return statistic_ids and meta data.""" units = hass.config.units + statistic_ids = {} with session_scope(hass=hass) as session: metadata = _get_metadata(hass, session, None, statistic_type) @@ -172,10 +230,34 @@ def list_statistic_ids(hass, statistic_type=None): unit = _configured_unit(meta["unit_of_measurement"], units) meta["unit_of_measurement"] = unit - return list(metadata.values()) + statistic_ids = { + meta["statistic_id"]: meta["unit_of_measurement"] + for meta in metadata.values() + } + + for platform in hass.data[DOMAIN].values(): + if not hasattr(platform, "list_statistic_ids"): + continue + platform_statistic_ids = platform.list_statistic_ids(hass, statistic_type) + + for statistic_id, unit in platform_statistic_ids.items(): + unit = _configured_unit(unit, units) + platform_statistic_ids[statistic_id] = unit + + statistic_ids = {**statistic_ids, **platform_statistic_ids} + + return [ + {"statistic_id": _id, "unit_of_measurement": unit} + for _id, unit in statistic_ids.items() + ] -def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None): +def statistics_during_period( + hass: HomeAssistant, + start_time: datetime, + end_time: datetime | None = None, + statistic_ids: list[str] | None = None, +) -> dict[str, list[dict[str, str]]]: """Return states changes during UTC period start_time - end_time.""" metadata = None with session_scope(hass=hass) as session: @@ -206,10 +288,14 @@ def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None start_time=start_time, end_time=end_time, metadata_ids=metadata_ids ) ) + if not stats: + return {} return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) -def get_last_statistics(hass, number_of_stats, statistic_id): +def get_last_statistics( + hass: HomeAssistant, number_of_stats: int, statistic_id: str +) -> dict[str, list[dict]]: """Return the last number_of_stats statistics for a statistic_id.""" statistic_ids = [statistic_id] with session_scope(hass=hass) as session: @@ -235,18 +321,20 @@ def get_last_statistics(hass, number_of_stats, statistic_id): number_of_stats=number_of_stats, metadata_id=metadata_id ) ) + if not stats: + return {} return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) def _sorted_statistics_to_dict( - hass, - stats, - statistic_ids, - metadata, -): + hass: HomeAssistant, + stats: list, + statistic_ids: list[str] | None, + metadata: dict[str, dict[str, str]], +) -> dict[str, list[dict]]: """Convert SQL results into JSON friendly data structure.""" - result = defaultdict(list) + result: dict = defaultdict(list) units = hass.config.units # Set all statistic IDs to empty lists in result set to maintain the order @@ -258,10 +346,12 @@ def _sorted_statistics_to_dict( _process_timestamp_to_utc_isoformat = process_timestamp_to_utc_isoformat # Append all statistic entries, and do unit conversion - for meta_id, group in groupby(stats, lambda state: state.metadata_id): + for meta_id, group in groupby(stats, lambda stat: stat.metadata_id): # type: ignore unit = metadata[meta_id]["unit_of_measurement"] statistic_id = metadata[meta_id]["statistic_id"] - convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) + convert: Callable[[Any, Any], float | None] = UNIT_CONVERSIONS.get( + unit, lambda x, units: x # type: ignore + ) ent_results = result[meta_id] ent_results.extend( { diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 80c90ccaa20..225eee6867f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -8,7 +8,7 @@ import functools import logging import os import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.session import Session @@ -91,7 +91,7 @@ def commit(session, work): return False -def execute(qry, to_native=False, validate_entity_ids=True): +def execute(qry, to_native=False, validate_entity_ids=True) -> list | None: """Query the database and convert the objects to HA native form. This method also retries a few times in the case of stale connections. @@ -135,6 +135,8 @@ def execute(qry, to_native=False, validate_entity_ids=True): raise time.sleep(QUERY_RETRY_WAIT) + return None + def validate_or_move_away_sqlite_database(dburl: str) -> bool: """Ensure that the database is valid or move it away.""" @@ -288,13 +290,13 @@ def end_incomplete_runs(session, start_time): session.add(run) -def retryable_database_job(description: str): +def retryable_database_job(description: str) -> Callable: """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: callable): + def decorator(job: Callable) -> Callable: @functools.wraps(job) def wrapper(instance: Recorder, *args, **kwargs): try: diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 9de33c67158..43658db907c 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -163,7 +163,7 @@ class RememberTheMilkConfiguration: return try: _LOGGER.debug("Loading configuration from file: %s", self._config_file_path) - with open(self._config_file_path) as config_file: + with open(self._config_file_path, encoding="utf8") as config_file: self._config = json.load(config_file) except ValueError: _LOGGER.error( @@ -174,7 +174,7 @@ class RememberTheMilkConfiguration: def save_config(self): """Write the configuration to a file.""" - with open(self._config_file_path, "w") as config_file: + with open(self._config_file_path, "w", encoding="utf8") as config_file: json.dump(self._config, config_file) def get_token(self, profile_name): diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 0fc4255615e..e67a4eda9a9 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -24,7 +25,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -142,9 +143,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await cast(EntityComponent, hass.data[DOMAIN]).async_unload_entry(entry) +@dataclass +class RemoteEntityDescription(ToggleEntityDescription): + """A class that describes remote entities.""" + + class RemoteEntity(ToggleEntity): """Base class for remote entities.""" + entity_description: RemoteEntityDescription _attr_activity_list: list[str] | None = None _attr_current_activity: str | None = None _attr_supported_features: int = 0 diff --git a/homeassistant/components/remote/translations/he.json b/homeassistant/components/remote/translations/he.json index 4b6283c1811..0cf0d53cd88 100644 --- a/homeassistant/components/remote/translations/he.json +++ b/homeassistant/components/remote/translations/he.json @@ -17,8 +17,8 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, - "title": "\u05de\u05b0\u05e8\u05d5\u05bc\u05d7\u05b8\u05e7" + "title": "\u05de\u05e8\u05d5\u05d7\u05e7" } \ No newline at end of file diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py new file mode 100644 index 00000000000..80433b2106e --- /dev/null +++ b/homeassistant/components/renault/__init__.py @@ -0,0 +1,45 @@ +"""Support for Renault devices.""" +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_LOCALE, DOMAIN, PLATFORMS +from .renault_hub import RenaultHub + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Load a config entry.""" + renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE]) + try: + login_success = await renault_hub.attempt_login( + config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] + ) + except aiohttp.ClientConnectionError as exc: + raise ConfigEntryNotReady() from exc + + if not login_success: + return False + + hass.data.setdefault(DOMAIN, {}) + await renault_hub.async_initialise(config_entry) + + hass.data[DOMAIN][config_entry.unique_id] = renault_hub + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + 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.unique_id) + + return unload_ok diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py new file mode 100644 index 00000000000..09a69f1f95f --- /dev/null +++ b/homeassistant/components/renault/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow to configure Renault component.""" +from __future__ import annotations + +from typing import Any + +from renault_api.const import AVAILABLE_LOCALES +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN +from .renault_hub import RenaultHub + + +class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Renault config flow.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the Renault config flow.""" + self.renault_config: dict[str, Any] = {} + self.renault_hub: RenaultHub | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a Renault config flow start. + + Ask the user for API keys. + """ + if user_input: + locale = user_input[CONF_LOCALE] + self.renault_config.update(user_input) + self.renault_config.update(AVAILABLE_LOCALES[locale]) + self.renault_hub = RenaultHub(self.hass, locale) + if not await self.renault_hub.attempt_login( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ): + return self._show_user_form({"base": "invalid_credentials"}) + return await self.async_step_kamereon() + return self._show_user_form() + + def _show_user_form(self, errors: dict[str, Any] | None = None) -> FlowResult: + """Show the API keys form.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors or {}, + ) + + async def async_step_kamereon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select Kamereon account.""" + if user_input: + await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID]) + self._abort_if_unique_id_configured() + + self.renault_config.update(user_input) + return self.async_create_entry( + title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config + ) + + assert self.renault_hub + accounts = await self.renault_hub.get_account_ids() + if len(accounts) == 0: + return self.async_abort(reason="kamereon_no_account") + if len(accounts) == 1: + await self.async_set_unique_id(accounts[0]) + self._abort_if_unique_id_configured() + + self.renault_config[CONF_KAMEREON_ACCOUNT_ID] = accounts[0] + return self.async_create_entry( + title=self.renault_config[CONF_KAMEREON_ACCOUNT_ID], + data=self.renault_config, + ) + + return self.async_show_form( + step_id="kamereon", + data_schema=vol.Schema( + {vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)} + ), + ) diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py new file mode 100644 index 00000000000..51f6c10c6f1 --- /dev/null +++ b/homeassistant/components/renault/const.py @@ -0,0 +1,15 @@ +"""Constants for the Renault component.""" +DOMAIN = "renault" + +CONF_LOCALE = "locale" +CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" + +DEFAULT_SCAN_INTERVAL = 300 # 5 minutes + +PLATFORMS = [ + "sensor", +] + +DEVICE_CLASS_PLUG_STATE = "renault__plug_state" +DEVICE_CLASS_CHARGE_STATE = "renault__charge_state" +DEVICE_CLASS_CHARGE_MODE = "renault__charge_mode" diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json new file mode 100644 index 00000000000..118848ad6dd --- /dev/null +++ b/homeassistant/components/renault/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "renault", + "name": "Renault", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/renault", + "requirements": [ + "renault-api==0.1.4" + ], + "codeowners": [ + "@epenet" + ], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/renault/renault_coordinator.py b/homeassistant/components/renault/renault_coordinator.py new file mode 100644 index 00000000000..b47a8507030 --- /dev/null +++ b/homeassistant/components/renault/renault_coordinator.py @@ -0,0 +1,72 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from collections.abc import Awaitable +from datetime import timedelta +import logging +from typing import Callable, TypeVar + +from renault_api.kamereon.exceptions import ( + AccessDeniedException, + KamereonResponseException, + NotSupportedException, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +T = TypeVar("T") + + +class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + update_interval: timedelta, + update_method: Callable[[], Awaitable[T]], + ) -> None: + """Initialise coordinator.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + update_method=update_method, + ) + self.access_denied = False + self.not_supported = False + + async def _async_update_data(self) -> T: + """Fetch the latest data from the source.""" + if self.update_method is None: + raise NotImplementedError("Update method not implemented") + try: + return await self.update_method() + except AccessDeniedException as err: + # Disable because the account is not allowed to access this Renault endpoint. + self.update_interval = None + self.access_denied = True + raise UpdateFailed(f"This endpoint is denied: {err}") from err + + except NotSupportedException as err: + # Disable because the vehicle does not support this Renault endpoint. + self.update_interval = None + self.not_supported = True + raise UpdateFailed(f"This endpoint is not supported: {err}") from err + + except KamereonResponseException as err: + # Other Renault errors. + raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_config_entry_first_refresh(self) -> None: + """Refresh data for the first time when a config entry is setup. + + Contrary to base implementation, we are not raising ConfigEntryNotReady + but only updating the `access_denied` and `not_supported` flags. + """ + await self._async_refresh(log_failures=False, raise_on_auth_failed=True) diff --git a/homeassistant/components/renault/renault_entities.py b/homeassistant/components/renault/renault_entities.py new file mode 100644 index 00000000000..9188a1f0757 --- /dev/null +++ b/homeassistant/components/renault/renault_entities.py @@ -0,0 +1,103 @@ +"""Base classes for Renault entities.""" +from __future__ import annotations + +from typing import Any, Generic, Optional, TypeVar + +from renault_api.kamereon.enums import ChargeState, PlugState +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleChargeModeData, + KamereonVehicleCockpitData, + KamereonVehicleHvacStatusData, +) + +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify + +from .renault_vehicle import RenaultVehicleProxy + +ATTR_LAST_UPDATE = "last_update" + +T = TypeVar("T") + + +class RenaultDataEntity(Generic[T], CoordinatorEntity[Optional[T]], Entity): + """Implementation of a Renault entity with a data coordinator.""" + + def __init__( + self, vehicle: RenaultVehicleProxy, entity_type: str, coordinator_key: str + ) -> None: + """Initialise entity.""" + super().__init__(vehicle.coordinators[coordinator_key]) + self.vehicle = vehicle + self._entity_type = entity_type + self._attr_device_info = self.vehicle.device_info + self._attr_name = entity_type + self._attr_unique_id = slugify( + f"{self.vehicle.details.vin}-{self._entity_type}" + ) + + @property + def available(self) -> bool: + """Return if entity is available.""" + # Data can succeed, but be empty + return super().available and self.coordinator.data is not None + + @property + def data(self) -> T | None: + """Return collected data.""" + return self.coordinator.data + + +class RenaultBatteryDataEntity(RenaultDataEntity[KamereonVehicleBatteryStatusData]): + """Implementation of a Renault entity with battery coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "battery") + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + last_update = self.data.timestamp if self.data else None + return {ATTR_LAST_UPDATE: last_update} + + @property + def is_charging(self) -> bool: + """Return charge state as boolean.""" + return ( + self.data is not None + and self.data.get_charging_status() == ChargeState.CHARGE_IN_PROGRESS + ) + + @property + def is_plugged_in(self) -> bool: + """Return plug state as boolean.""" + return ( + self.data is not None and self.data.get_plug_status() == PlugState.PLUGGED + ) + + +class RenaultChargeModeDataEntity(RenaultDataEntity[KamereonVehicleChargeModeData]): + """Implementation of a Renault entity with charge_mode coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "charge_mode") + + +class RenaultCockpitDataEntity(RenaultDataEntity[KamereonVehicleCockpitData]): + """Implementation of a Renault entity with cockpit coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "cockpit") + + +class RenaultHVACDataEntity(RenaultDataEntity[KamereonVehicleHvacStatusData]): + """Implementation of a Renault entity with hvac_status coordinator.""" + + def __init__(self, vehicle: RenaultVehicleProxy, entity_type: str) -> None: + """Initialise entity.""" + super().__init__(vehicle, entity_type, "hvac_status") diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py new file mode 100644 index 00000000000..51e356934bb --- /dev/null +++ b/homeassistant/components/renault/renault_hub.py @@ -0,0 +1,78 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.renault_account import RenaultAccount +from renault_api.renault_client import RenaultClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL +from .renault_vehicle import RenaultVehicleProxy + +LOGGER = logging.getLogger(__name__) + + +class RenaultHub: + """Handle account communication with Renault servers.""" + + def __init__(self, hass: HomeAssistant, locale: str) -> None: + """Initialise proxy.""" + LOGGER.debug("Creating RenaultHub") + self._hass = hass + self._client = RenaultClient( + websession=async_get_clientsession(self._hass), locale=locale + ) + self._account: RenaultAccount | None = None + self._vehicles: dict[str, RenaultVehicleProxy] = {} + + async def attempt_login(self, username: str, password: str) -> bool: + """Attempt login to Renault servers.""" + try: + await self._client.session.login(username, password) + except InvalidCredentialsException as ex: + LOGGER.error("Login to Renault failed: %s", ex.error_details) + else: + return True + return False + + async def async_initialise(self, config_entry: ConfigEntry) -> None: + """Set up proxy.""" + account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] + scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL) + + self._account = await self._client.get_api_account(account_id) + vehicles = await self._account.get_vehicles() + if vehicles.vehicleLinks: + for vehicle_link in vehicles.vehicleLinks: + if vehicle_link.vin and vehicle_link.vehicleDetails: + # Generate vehicle proxy + vehicle = RenaultVehicleProxy( + hass=self._hass, + vehicle=await self._account.get_api_vehicle(vehicle_link.vin), + details=vehicle_link.vehicleDetails, + scan_interval=scan_interval, + ) + await vehicle.async_initialise() + self._vehicles[vehicle_link.vin] = vehicle + + async def get_account_ids(self) -> list[str]: + """Get Kamereon account ids.""" + accounts = [] + for account in await self._client.get_api_accounts(): + vehicles = await account.get_vehicles() + + # Only add the account if it has linked vehicles. + if vehicles.vehicleLinks: + accounts.append(account.account_id) + return accounts + + @property + def vehicles(self) -> dict[str, RenaultVehicleProxy]: + """Get list of vehicles.""" + return self._vehicles diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py new file mode 100644 index 00000000000..09e3de9adab --- /dev/null +++ b/homeassistant/components/renault/renault_vehicle.py @@ -0,0 +1,146 @@ +"""Proxy to handle account communication with Renault servers.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import cast + +from renault_api.kamereon import models +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from .const import DOMAIN +from .renault_coordinator import RenaultDataUpdateCoordinator + +LOGGER = logging.getLogger(__name__) + + +class RenaultVehicleProxy: + """Handle vehicle communication with Renault servers.""" + + def __init__( + self, + hass: HomeAssistant, + vehicle: RenaultVehicle, + details: models.KamereonVehicleDetails, + scan_interval: timedelta, + ) -> None: + """Initialise vehicle proxy.""" + self.hass = hass + self._vehicle = vehicle + self._details = details + self._device_info: DeviceInfo = { + "identifiers": {(DOMAIN, cast(str, details.vin))}, + "manufacturer": (details.get_brand_label() or "").capitalize(), + "model": (details.get_model_label() or "").capitalize(), + "name": details.registrationNumber or "", + "sw_version": details.get_model_code() or "", + } + self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {} + self.hvac_target_temperature = 21 + self._scan_interval = scan_interval + + @property + def details(self) -> models.KamereonVehicleDetails: + """Return the specs of the vehicle.""" + return self._details + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + return self._device_info + + async def async_initialise(self) -> None: + """Load available sensors.""" + if await self.endpoint_available("cockpit"): + self.coordinators["cockpit"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} cockpit", + update_method=self.get_cockpit, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("hvac-status"): + self.coordinators["hvac_status"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} hvac_status", + update_method=self.get_hvac_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if self.details.uses_electricity(): + if await self.endpoint_available("battery-status"): + self.coordinators["battery"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} battery", + update_method=self.get_battery_status, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + if await self.endpoint_available("charge-mode"): + self.coordinators["charge_mode"] = RenaultDataUpdateCoordinator( + self.hass, + LOGGER, + # Name of the data. For logging purposes. + name=f"{self.details.vin} charge_mode", + update_method=self.get_charge_mode, + # Polling interval. Will only be polled if there are subscribers. + update_interval=self._scan_interval, + ) + # Check all coordinators + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in self.coordinators.values() + ) + ) + for key in list(self.coordinators): + # list() to avoid Runtime iteration error + coordinator = self.coordinators[key] + if coordinator.not_supported: + # Remove endpoint as it is not supported for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is not supported for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + elif coordinator.access_denied: + # Remove endpoint as it is denied for this vehicle. + LOGGER.error( + "Ignoring endpoint %s as it is denied for this vehicle: %s", + coordinator.name, + coordinator.last_exception, + ) + del self.coordinators[key] + + async def endpoint_available(self, endpoint: str) -> bool: + """Ensure the endpoint is available to avoid unnecessary queries.""" + return await self._vehicle.supports_endpoint( + endpoint + ) and await self._vehicle.has_contract_for_endpoint(endpoint) + + async def get_battery_status(self) -> models.KamereonVehicleBatteryStatusData: + """Get battery status information from vehicle.""" + return await self._vehicle.get_battery_status() + + async def get_charge_mode(self) -> models.KamereonVehicleChargeModeData: + """Get charge mode information from vehicle.""" + return await self._vehicle.get_charge_mode() + + async def get_cockpit(self) -> models.KamereonVehicleCockpitData: + """Get cockpit information from vehicle.""" + return await self._vehicle.get_cockpit() + + async def get_hvac_status(self) -> models.KamereonVehicleHvacStatusData: + """Get hvac status information from vehicle.""" + return await self._vehicle.get_hvac_status() diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py new file mode 100644 index 00000000000..8403a04d001 --- /dev/null +++ b/homeassistant/components/renault/sensor.py @@ -0,0 +1,277 @@ +"""Support for Renault sensors.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.util import slugify + +from .const import ( + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from .renault_entities import ( + RenaultBatteryDataEntity, + RenaultChargeModeDataEntity, + RenaultCockpitDataEntity, + RenaultDataEntity, + RenaultHVACDataEntity, +) +from .renault_hub import RenaultHub +from .renault_vehicle import RenaultVehicleProxy + +ATTR_BATTERY_AVAILABLE_ENERGY = "battery_available_energy" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renault entities from config entry.""" + proxy: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + entities = await get_entities(proxy) + async_add_entities(entities) + + +async def get_entities(proxy: RenaultHub) -> list[RenaultDataEntity]: + """Create Renault entities for all vehicles.""" + entities = [] + for vehicle in proxy.vehicles.values(): + entities.extend(await get_vehicle_entities(vehicle)) + return entities + + +async def get_vehicle_entities(vehicle: RenaultVehicleProxy) -> list[RenaultDataEntity]: + """Create Renault entities for single vehicle.""" + entities: list[RenaultDataEntity] = [] + if "cockpit" in vehicle.coordinators: + entities.append(RenaultMileageSensor(vehicle, "Mileage")) + if vehicle.details.uses_fuel(): + entities.append(RenaultFuelAutonomySensor(vehicle, "Fuel Autonomy")) + entities.append(RenaultFuelQuantitySensor(vehicle, "Fuel Quantity")) + if "hvac_status" in vehicle.coordinators: + entities.append(RenaultOutsideTemperatureSensor(vehicle, "Outside Temperature")) + if "battery" in vehicle.coordinators: + entities.append(RenaultBatteryLevelSensor(vehicle, "Battery Level")) + entities.append(RenaultChargeStateSensor(vehicle, "Charge State")) + entities.append( + RenaultChargingRemainingTimeSensor(vehicle, "Charging Remaining Time") + ) + entities.append(RenaultChargingPowerSensor(vehicle, "Charging Power")) + entities.append(RenaultPlugStateSensor(vehicle, "Plug State")) + entities.append(RenaultBatteryAutonomySensor(vehicle, "Battery Autonomy")) + entities.append(RenaultBatteryTemperatureSensor(vehicle, "Battery Temperature")) + if "charge_mode" in vehicle.coordinators: + entities.append(RenaultChargeModeSensor(vehicle, "Charge Mode")) + return entities + + +class RenaultBatteryAutonomySensor(RenaultBatteryDataEntity, SensorEntity): + """Battery autonomy sensor.""" + + _attr_icon = "mdi:ev-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryAutonomy if self.data else None + + +class RenaultBatteryLevelSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Level sensor.""" + + _attr_device_class = DEVICE_CLASS_BATTERY + _attr_unit_of_measurement = PERCENTAGE + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryLevel if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + return icon_for_battery_level( + battery_level=self.state, charging=self.is_charging + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of this entity.""" + attrs = super().extra_state_attributes + attrs[ATTR_BATTERY_AVAILABLE_ENERGY] = ( + self.data.batteryAvailableEnergy if self.data else None + ) + return attrs + + +class RenaultBatteryTemperatureSensor(RenaultBatteryDataEntity, SensorEntity): + """Battery Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.batteryTemperature if self.data else None + + +class RenaultChargeModeSensor(RenaultChargeModeDataEntity, SensorEntity): + """Charge Mode sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_MODE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + return self.data.chargeMode if self.data else None + + @property + def icon(self) -> str: + """Icon handling.""" + if self.data and self.data.chargeMode == "schedule_mode": + return "mdi:calendar-clock" + return "mdi:calendar-remove" + + +class RenaultChargeStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Charge State sensor.""" + + _attr_device_class = DEVICE_CLASS_CHARGE_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + charging_status = self.data.get_charging_status() if self.data else None + return slugify(charging_status.name) if charging_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:flash" if self.is_charging else "mdi:flash-off" + + +class RenaultChargingRemainingTimeSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Remaining Time sensor.""" + + _attr_icon = "mdi:timer" + _attr_unit_of_measurement = TIME_MINUTES + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return self.data.chargingRemainingTime if self.data else None + + +class RenaultChargingPowerSensor(RenaultBatteryDataEntity, SensorEntity): + """Charging Power sensor.""" + + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_unit_of_measurement = POWER_KILO_WATT + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + if not self.data or self.data.chargingInstantaneousPower is None: + return None + if self.vehicle.details.reports_charging_power_in_watts(): + # Need to convert to kilowatts + return self.data.chargingInstantaneousPower / 1000 + return self.data.chargingInstantaneousPower + + +class RenaultFuelAutonomySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel autonomy sensor.""" + + _attr_icon = "mdi:gas-station" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelAutonomy) + if self.data and self.data.fuelAutonomy is not None + else None + ) + + +class RenaultFuelQuantitySensor(RenaultCockpitDataEntity, SensorEntity): + """Fuel quantity sensor.""" + + _attr_icon = "mdi:fuel" + _attr_unit_of_measurement = VOLUME_LITERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.fuelQuantity) + if self.data and self.data.fuelQuantity is not None + else None + ) + + +class RenaultMileageSensor(RenaultCockpitDataEntity, SensorEntity): + """Mileage sensor.""" + + _attr_icon = "mdi:sign-direction" + _attr_unit_of_measurement = LENGTH_KILOMETERS + + @property + def state(self) -> int | None: + """Return the state of this entity.""" + return ( + round(self.data.totalMileage) + if self.data and self.data.totalMileage is not None + else None + ) + + +class RenaultOutsideTemperatureSensor(RenaultHVACDataEntity, SensorEntity): + """HVAC Outside Temperature sensor.""" + + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_CELSIUS + + @property + def state(self) -> float | None: + """Return the state of this entity.""" + return self.data.externalTemperature if self.data else None + + +class RenaultPlugStateSensor(RenaultBatteryDataEntity, SensorEntity): + """Plug State sensor.""" + + _attr_device_class = DEVICE_CLASS_PLUG_STATE + + @property + def state(self) -> str | None: + """Return the state of this entity.""" + plug_status = self.data.get_plug_status() if self.data else None + return slugify(plug_status.name) if plug_status is not None else None + + @property + def icon(self) -> str: + """Icon handling.""" + return "mdi:power-plug" if self.is_plugged_in else "mdi:power-plug-off" diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json new file mode 100644 index 00000000000..942c8b4a06c --- /dev/null +++ b/homeassistant/components/renault/strings.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "username": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "title": "Set Renault credentials" + } + } + } +} diff --git a/homeassistant/components/renault/translations/ca.json b/homeassistant/components/renault/translations/ca.json new file mode 100644 index 00000000000..8315d35b87b --- /dev/null +++ b/homeassistant/components/renault/translations/ca.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat", + "kamereon_no_account": "No s'ha pogut trobar cap compte Kamereon." + }, + "error": { + "invalid_credentials": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID del compte Kamereon" + }, + "title": "Seleccioneu l'ID del compte Kamereon" + }, + "user": { + "data": { + "locale": "Llengua/regi\u00f3", + "password": "Contrasenya", + "username": "Correu electr\u00f2nic" + }, + "title": "Defineix les credencials de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/cs.json b/homeassistant/components/renault/translations/cs.json new file mode 100644 index 00000000000..d731b4c2ec0 --- /dev/null +++ b/homeassistant/components/renault/translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "invalid_credentials": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "E-mail" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/de.json b/homeassistant/components/renault/translations/de.json new file mode 100644 index 00000000000..16650b8d63e --- /dev/null +++ b/homeassistant/components/renault/translations/de.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "kamereon_no_account": "Kamereon-Konto kann nicht gefunden werden." + }, + "error": { + "invalid_credentials": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon-Kontonummer" + }, + "title": "Kamereon-Kontonummer ausw\u00e4hlen" + }, + "user": { + "data": { + "locale": "Gebietsschema", + "password": "Passwort", + "username": "E-Mail" + }, + "title": "Renault-Anmeldeinformationen festlegen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/en.json b/homeassistant/components/renault/translations/en.json new file mode 100644 index 00000000000..87186e6f59c --- /dev/null +++ b/homeassistant/components/renault/translations/en.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured", + "kamereon_no_account": "Unable to find Kamereon account." + }, + "error": { + "invalid_credentials": "Invalid authentication" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Select Kamereon account id" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Password", + "username": "Email" + }, + "title": "Set Renault credentials" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/et.json b/homeassistant/components/renault/translations/et.json new file mode 100644 index 00000000000..bae0db1aed7 --- /dev/null +++ b/homeassistant/components/renault/translations/et.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", + "kamereon_no_account": "Kamereoni kontot ei leitud." + }, + "error": { + "invalid_credentials": "Tuvastamine nurjus" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereoni konto ID" + }, + "title": "Vali Kamereoni konto ID" + }, + "user": { + "data": { + "locale": "Riigi kood (n\u00e4iteks EE)", + "password": "Salas\u00f5na", + "username": "E-posti aadress" + }, + "title": "M\u00e4\u00e4ra Renault sidumise parameetrid" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/fr.json b/homeassistant/components/renault/translations/fr.json new file mode 100644 index 00000000000..874a9b8df67 --- /dev/null +++ b/homeassistant/components/renault/translations/fr.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "kamereon_no_account": "Impossible de trouver le compte Kamereon." + }, + "error": { + "invalid_credentials": "Authentification incorrecte" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Identifiant du compte Kamereon" + }, + "title": "S\u00e9lectionner l'identifiant du compte Kamereon" + }, + "user": { + "data": { + "locale": "Lieu", + "password": "Mot de passe", + "username": "Email" + }, + "title": "D\u00e9finir les informations d'identification de Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/he.json b/homeassistant/components/renault/translations/he.json new file mode 100644 index 00000000000..d20e2d36a81 --- /dev/null +++ b/homeassistant/components/renault/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_credentials": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "user": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05d3\u05d5\u05d0\"\u05dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/it.json b/homeassistant/components/renault/translations/it.json new file mode 100644 index 00000000000..37ba94b3cdf --- /dev/null +++ b/homeassistant/components/renault/translations/it.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "kamereon_no_account": "Impossibile trovare l'account Kamereon." + }, + "error": { + "invalid_credentials": "Autenticazione non valida" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID account Kamereon" + }, + "title": "Seleziona l'id dell'account Kamereon" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Password", + "username": "E-mail" + }, + "title": "Imposta le credenziali Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/nl.json b/homeassistant/components/renault/translations/nl.json new file mode 100644 index 00000000000..4840dd0c07b --- /dev/null +++ b/homeassistant/components/renault/translations/nl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "kamereon_no_account": "Kan Kamereon-account niet vinden." + }, + "error": { + "invalid_credentials": "Ongeldige authenticatie" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon account id" + }, + "title": "Selecteer Kamereon-account-ID" + }, + "user": { + "data": { + "locale": "Locale", + "password": "Wachtwoord", + "username": "E-mail" + }, + "title": "Renault-inloggegevens instellen" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/no.json b/homeassistant/components/renault/translations/no.json new file mode 100644 index 00000000000..f367c8c540d --- /dev/null +++ b/homeassistant/components/renault/translations/no.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-Post" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/pl.json b/homeassistant/components/renault/translations/pl.json new file mode 100644 index 00000000000..1d518cc14fb --- /dev/null +++ b/homeassistant/components/renault/translations/pl.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "kamereon_no_account": "Nie mo\u017cna znale\u017a\u0107 konta Kamereon." + }, + "error": { + "invalid_credentials": "Niepoprawne uwierzytelnienie" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Identyfikator konta Kamereon" + }, + "title": "Wyb\u00f3r identyfikatora konta Kamereon" + }, + "user": { + "data": { + "locale": "Ustawienia regionalne", + "password": "Has\u0142o", + "username": "Adres e-mail" + }, + "title": "Dane logowania Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/ru.json b/homeassistant/components/renault/translations/ru.json new file mode 100644 index 00000000000..822d42b6117 --- /dev/null +++ b/homeassistant/components/renault/translations/ru.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.", + "kamereon_no_account": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043d\u0430\u0439\u0442\u0438 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Kamereon." + }, + "error": { + "invalid_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 ID \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Kamereon" + }, + "user": { + "data": { + "locale": "\u0420\u0435\u0433\u0438\u043e\u043d", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "title": "\u0423\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 Renault" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/renault/translations/zh-Hant.json b/homeassistant/components/renault/translations/zh-Hant.json new file mode 100644 index 00000000000..4ae5413499d --- /dev/null +++ b/homeassistant/components/renault/translations/zh-Hant.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "kamereon_no_account": "\u627e\u4e0d\u5230 Kamereon \u5e33\u865f\u3002" + }, + "error": { + "invalid_credentials": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "kamereon": { + "data": { + "kamereon_account_id": "Kamereon \u5e33\u865f ID" + }, + "title": "\u9078\u64c7 Kamereon \u5e33\u865f ID" + }, + "user": { + "data": { + "locale": "\u4f4d\u7f6e", + "password": "\u5bc6\u78bc", + "username": "\u96fb\u5b50\u90f5\u4ef6" + }, + "title": "\u8a2d\u5b9a Renault \u6191\u8b49" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index c104fc447e2..08306396e96 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, CONF_SENSORS, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) @@ -110,23 +111,31 @@ def has_all_unique_names(value): SENSOR_TYPES = { # Type, Unit, Icon, post - "bed_temperature": ["temperature", TEMP_CELSIUS, "mdi:thermometer", "_bed_"], + "bed_temperature": [ + "temperature", + TEMP_CELSIUS, + None, + "_bed_", + DEVICE_CLASS_TEMPERATURE, + ], "extruder_temperature": [ "temperature", TEMP_CELSIUS, - "mdi:thermometer", + None, "_extruder_", + DEVICE_CLASS_TEMPERATURE, ], "chamber_temperature": [ "temperature", TEMP_CELSIUS, - "mdi:thermometer", + None, "_chamber_", + DEVICE_CLASS_TEMPERATURE, ], - "current_state": ["state", None, "mdi:printer-3d", ""], - "current_job": ["progress", PERCENTAGE, "mdi:file-percent", "_current_job"], - "job_end": ["progress", None, "mdi:clock-end", "_job_end"], - "job_start": ["progress", None, "mdi:clock-start", "_job_start"], + "current_state": ["state", None, "mdi:printer-3d", "", None], + "current_job": ["progress", PERCENTAGE, "mdi:file-percent", "_current_job", None], + "job_end": ["progress", None, "mdi:clock-end", "_job_end", None], + "job_start": ["progress", None, "mdi:clock-start", "_job_start", None], } SENSOR_SCHEMA = vol.Schema( diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py index 77a3c51e9cf..46818095647 100644 --- a/homeassistant/components/repetier/sensor.py +++ b/homeassistant/components/repetier/sensor.py @@ -59,6 +59,7 @@ class RepetierSensor(SensorEntity): self._printer_id = printer_id self._sensor_type = sensor_type self._state = None + self._attr_device_class = SENSOR_TYPES[self._sensor_type][4] @property def available(self) -> bool: diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index a4be36df998..44e1d537408 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -23,7 +23,8 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, DEGREE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, EVENT_HOMEASSISTANT_STOP, LENGTH_MILLIMETERS, @@ -35,7 +36,6 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, TEMP_CELSIUS, UV_INDEX, - VOLT, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -44,6 +44,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( ATTR_EVENT, + COMMAND_GROUP_LIST, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEBUG, @@ -87,11 +88,11 @@ DATA_TYPES = OrderedDict( ("Wind gust", SPEED_METERS_PER_SECOND), ("Chill", TEMP_CELSIUS), ("Count", "count"), - ("Current Ch. 1", ELECTRICAL_CURRENT_AMPERE), - ("Current Ch. 2", ELECTRICAL_CURRENT_AMPERE), - ("Current Ch. 3", ELECTRICAL_CURRENT_AMPERE), - ("Voltage", VOLT), - ("Current", ELECTRICAL_CURRENT_AMPERE), + ("Current Ch. 1", ELECTRIC_CURRENT_AMPERE), + ("Current Ch. 2", ELECTRIC_CURRENT_AMPERE), + ("Current Ch. 3", ELECTRIC_CURRENT_AMPERE), + ("Voltage", ELECTRIC_POTENTIAL_VOLT), + ("Current", ELECTRIC_CURRENT_AMPERE), ("Battery numeric", PERCENTAGE), ("Rssi numeric", SIGNAL_STRENGTH_DECIBELS_MILLIWATT), ] @@ -465,6 +466,9 @@ class RfxtrxEntity(RestoreEntity): self._event = event self._device_id = device_id self._unique_id = "_".join(x for x in self._device_id) + # If id_string is 213c7f2:1, the group_id is 213c7f2, and the device will respond to + # group events regardless of their group indices. + (self._group_id, _, _) = device.id_string.partition(":") async def async_added_to_hass(self): """Restore RFXtrx device state (ON/OFF).""" @@ -520,6 +524,15 @@ class RfxtrxEntity(RestoreEntity): "model": self._device.type_string, } + def _event_applies(self, event, device_id): + """Check if event applies to me.""" + if "Command" in event.values and event.values["Command"] in COMMAND_GROUP_LIST: + (group_id, _, _) = event.device.id_string.partition(":") + return group_id == self._group_id + + # Otherwise, the event only applies to the matching device. + return device_id == self._device_id + def _apply_event(self, event): """Apply a received event.""" self._event = event diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py index 78eb49740d5..9e3d24cdb6a 100644 --- a/homeassistant/components/rfxtrx/binary_sensor.py +++ b/homeassistant/components/rfxtrx/binary_sensor.py @@ -233,7 +233,7 @@ class RfxtrxBinarySensor(RfxtrxEntity, BinarySensorEntity): @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" - if device_id != self._device_id: + if not self._event_applies(event, device_id): return _LOGGER.debug( diff --git a/homeassistant/components/rfxtrx/const.py b/homeassistant/components/rfxtrx/const.py index 1f36b00e184..d457435f85c 100644 --- a/homeassistant/components/rfxtrx/const.py +++ b/homeassistant/components/rfxtrx/const.py @@ -19,6 +19,7 @@ COMMAND_ON_LIST = [ "On", "Up", "Stop", + "Group on", "Open (inline relay)", "Stop (inline relay)", "Enable sun automation", @@ -26,11 +27,17 @@ COMMAND_ON_LIST = [ COMMAND_OFF_LIST = [ "Off", + "Group off", "Down", "Close (inline relay)", "Disable sun automation", ] +COMMAND_GROUP_LIST = [ + "Group on", + "Group off", +] + ATTR_EVENT = "event" SERVICE_SEND = "send" diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py index 96c066d5f3e..60ddb9a4d16 100644 --- a/homeassistant/components/rfxtrx/switch.py +++ b/homeassistant/components/rfxtrx/switch.py @@ -117,12 +117,10 @@ class RfxtrxSwitch(RfxtrxCommandEntity, SwitchEntity): @callback def _handle_event(self, event, device_id): """Check if event applies to me and update.""" - if device_id != self._device_id: - return + if self._event_applies(event, device_id): + self._apply_event(event) - self._apply_event(event) - - self.async_write_ha_state() + self.async_write_ha_state() @property def is_on(self): diff --git a/homeassistant/components/rfxtrx/translations/de.json b/homeassistant/components/rfxtrx/translations/de.json index a806afb6dbf..7b006782d96 100644 --- a/homeassistant/components/rfxtrx/translations/de.json +++ b/homeassistant/components/rfxtrx/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert. Nur eine Konfiguration m\u00f6glich.", + "already_configured": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { diff --git a/homeassistant/components/rfxtrx/translations/hu.json b/homeassistant/components/rfxtrx/translations/hu.json index 20ef3db6171..d8a27a3173b 100644 --- a/homeassistant/components/rfxtrx/translations/hu.json +++ b/homeassistant/components/rfxtrx/translations/hu.json @@ -5,14 +5,19 @@ "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s" + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "one": "\u00dcres", + "other": "\u00dcres" }, "step": { + "one": "\u00dcres", + "other": "\u00dcres", "setup_network": { "data": { "host": "Hoszt", "port": "Port" - } + }, + "title": "V\u00e1lassza ki a csatlakoz\u00e1si c\u00edmet" }, "setup_serial": { "data": { @@ -25,22 +30,50 @@ "device": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" }, "title": "El\u00e9r\u00e9si \u00fat" + }, + "user": { + "data": { + "type": "Kapcsolat t\u00edpusa" + }, + "title": "V\u00e1lassza ki a kapcsolat t\u00edpus\u00e1t" } } }, + "one": "\u00dcres", "options": { "error": { "already_configured_device": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "invalid_event_code": "\u00c9rv\u00e9nytelen esem\u00e9nyk\u00f3d", + "invalid_input_2262_off": "\u00c9rv\u00e9nytelen bemenet a kikapcsol\u00e1si parancshoz", + "invalid_input_2262_on": "\u00c9rv\u00e9nytelen bemenet a parancshoz", + "invalid_input_off_delay": "\u00c9rv\u00e9nytelen bemenet a kikapcsol\u00e1si k\u00e9sleltet\u00e9shez", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { "prompt_options": { "data": { + "automatic_add": "Enged\u00e9lyezze az automatikus hozz\u00e1ad\u00e1st", + "debug": "Enged\u00e9lyezze a hibakeres\u00e9st", + "device": "V\u00e1lassza ki a konfigur\u00e1lni k\u00edv\u00e1nt eszk\u00f6zt", + "event_code": "\u00cdrja be a hozz\u00e1adni k\u00edv\u00e1nt esem\u00e9ny k\u00f3dj\u00e1t", "remove_device": "V\u00e1lassza ki a t\u00f6r\u00f6lni k\u00edv\u00e1nt eszk\u00f6zt" }, "title": "Rfxtrx opci\u00f3k" + }, + "set_device_options": { + "data": { + "command_off": "Adatbitek \u00e9rt\u00e9ke a parancs kikapcsol\u00e1s\u00e1hoz", + "command_on": "Adatbitek \u00e9rt\u00e9ke a parancshoz", + "data_bit": "Adatbitek sz\u00e1ma", + "fire_event": "Eszk\u00f6zesem\u00e9ny enged\u00e9lyez\u00e9se", + "off_delay": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s", + "off_delay_enabled": "Kikapcsol\u00e1si k\u00e9sleltet\u00e9s enged\u00e9lyez\u00e9se", + "replace_device": "V\u00e1lassza ki a cser\u00e9lni k\u00edv\u00e1nt eszk\u00f6zt", + "signal_repetitions": "A jelism\u00e9tl\u00e9sek sz\u00e1ma" + }, + "title": "Konfigur\u00e1lja az eszk\u00f6z be\u00e1ll\u00edt\u00e1sait" } } - } + }, + "other": "\u00dcres" } \ No newline at end of file diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py index 28d686df06a..d2c412a691d 100644 --- a/homeassistant/components/ring/binary_sensor.py +++ b/homeassistant/components/ring/binary_sensor.py @@ -30,8 +30,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] for device_type in ("doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type in SENSOR_TYPES: - if device_type not in SENSOR_TYPES[sensor_type][1]: + for sensor_type, sensor in SENSOR_TYPES.items(): + if device_type not in sensor[1]: continue for device in devices[device_type]: diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index fb1c38fcbde..97fb8ec9d21 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -19,19 +19,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sensors = [] for device_type in ("chimes", "doorbots", "authorized_doorbots", "stickup_cams"): - for sensor_type in SENSOR_TYPES: - if device_type not in SENSOR_TYPES[sensor_type][1]: + for sensor_type, sensor in SENSOR_TYPES.items(): + if device_type not in sensor[1]: continue for device in devices[device_type]: if device_type == "battery" and device.battery_life is None: continue - sensors.append( - SENSOR_TYPES[sensor_type][6]( - config_entry.entry_id, device, sensor_type - ) - ) + sensors.append(sensor[6](config_entry.entry_id, device, sensor_type)) async_add_entities(sensors) diff --git a/homeassistant/components/risco/__init__.py b/homeassistant/components/risco/__init__.py index 4b33873e88d..ce43ce09988 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -57,10 +57,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def start_platforms(): await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) await events_coordinator.async_refresh() diff --git a/homeassistant/components/risco/translations/ar.json b/homeassistant/components/risco/translations/ar.json new file mode 100644 index 00000000000..cb40e35cd5d --- /dev/null +++ b/homeassistant/components/risco/translations/ar.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "ha_to_risco": { + "description": "\u062d\u062f\u062f \u0627\u0644\u062d\u0627\u0644\u0629 \u0627\u0644\u062a\u064a \u062a\u0631\u064a\u062f \u0636\u0628\u0637 \u0645\u0646\u0628\u0647 Risco \u0639\u0644\u064a\u0647\u0627 \u0639\u0646\u062f \u062a\u0641\u0639\u064a\u0644 \u0625\u0646\u0630\u0627\u0631 Home Assistant" + }, + "risco_to_ha": { + "description": "\u062d\u062f\u062f \u0627\u0644\u062d\u0627\u0644\u0629 \u0627\u0644\u062a\u064a \u0633\u064a\u0628\u0644\u063a \u0639\u0646\u0647\u0627 \u0625\u0646\u0630\u0627\u0631 Home Assistant \u0644\u0643\u0644 \u062d\u0627\u0644\u0629 \u0623\u0628\u0644\u063a\u062a \u0639\u0646\u0647\u0627 Risco" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/risco/translations/de.json b/homeassistant/components/risco/translations/de.json index 424a93f3eb7..a5ebcab51b5 100644 --- a/homeassistant/components/risco/translations/de.json +++ b/homeassistant/components/risco/translations/de.json @@ -27,7 +27,7 @@ "armed_home": "Aktiv, zu Hause", "armed_night": "Aktiv, Nacht" }, - "description": "W\u00e4hlen Sie aus, in welchen Zustand Ihr Risco-Alarm versetzt werden soll, wenn Sie den Alarm des Home Assistant scharf schalten", + "description": "W\u00e4hle, in welchen Zustand dein Risco-Alarm versetzt werden soll, wenn du den Alarm des Home Assistant scharf schaltest", "title": "Home Assistant Zust\u00e4nde den Risco Zust\u00e4nden zuordnen" }, "init": { @@ -47,7 +47,7 @@ "arm": "Aktiv, abwesend", "partial_arm": "Teilweise aktiv (STAY)" }, - "description": "W\u00e4hlen Sie aus, welchen Zustand Ihr Home Assistant-Alarm f\u00fcr jeden von Risco gemeldeten Zustand melden soll", + "description": "W\u00e4hle aus, welchen Zustand dein Home Assistant-Alarm f\u00fcr jeden von Risco gemeldeten Zustand melden soll", "title": "Risco-Zust\u00e4nde den Home Assistant-Zust\u00e4nden zuordnen" } } diff --git a/homeassistant/components/risco/translations/hu.json b/homeassistant/components/risco/translations/hu.json index ee57b9488dc..aaa7974cd4a 100644 --- a/homeassistant/components/risco/translations/hu.json +++ b/homeassistant/components/risco/translations/hu.json @@ -20,8 +20,35 @@ }, "options": { "step": { + "ha_to_risco": { + "data": { + "armed_away": "\u00c9les\u00edtve t\u00e1vol", + "armed_custom_bypass": "\u00c9les\u00edtve (egy\u00e9ni)", + "armed_home": "\u00c9les\u00edtve (otthon)", + "armed_night": "\u00c9les\u00edtve (\u00e9jszakai)" + }, + "description": "V\u00e1lassza ki, hogy milyen \u00e1llapotba \u00e1ll\u00edtsa a Risco riaszt\u00e1st a Home Assistant riaszt\u00e1s \u00e9les\u00edt\u00e9sekor", + "title": "A Home Assistant \u00e1llapotok megjelen\u00edt\u00e9se Risco \u00e1llapotokba" + }, "init": { + "data": { + "code_arm_required": "Az \u00e9les\u00edt\u00e9shez PIN-k\u00f3d sz\u00fcks\u00e9ges", + "code_disarm_required": "A hat\u00e1stalan\u00edt\u00e1shoz a PIN-k\u00f3d sz\u00fcks\u00e9ges", + "scan_interval": "Milyen gyakran kell lek\u00e9rdezni Risco-t (m\u00e1sodpercben)" + }, "title": "Be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" + }, + "risco_to_ha": { + "data": { + "A": "A csoport", + "B": "B csoport", + "C": "C csoport", + "D": "D csoport", + "arm": "\u00c9les\u00edtve (t\u00e1voll\u00e9t)", + "partial_arm": "R\u00e9szben \u00e9les\u00edtve (otthon)" + }, + "description": "V\u00e1lassza ki, hogy a Home Assistant riaszt\u00e1sa milyen \u00e1llapotot jelent a Risco \u00e1ltal jelentett minden \u00e1llapotr\u00f3l", + "title": "A Risco \u00e1llapotok hozz\u00e1rendel\u00e9se Home Assistant \u00e1llapotokhoz" } } } diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index 84fc5ed2cf5..ee2a517a3f7 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -11,7 +11,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN, HUBLOT +from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN PLATFORMS = ["binary_sensor", "number", "select", "sensor", "switch"] @@ -23,8 +23,7 @@ UPDATE_INTERVAL = timedelta(seconds=30) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" session = async_get_clientsession(hass) - account = Account(session=session) - account.data = {ACCOUNT_HASH: entry.data.get(ACCOUNT_HASH)} + account = Account(session=session, account_hash=entry.data[ACCOUNT_HASH]) try: account_devices = await account.get_devices() @@ -37,7 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } for device in account_devices: - hublot = device.hub_data[HUBLOT] + hublot = device.hublot coordinator = RitualsDataUpdateCoordinator(hass, device) await coordinator.async_refresh() @@ -50,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +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: @@ -68,7 +67,7 @@ class RitualsDataUpdateCoordinator(DataUpdateCoordinator): super().__init__( hass, _LOGGER, - name=f"{DOMAIN}-{device.hub_data[HUBLOT]}", + name=f"{DOMAIN}-{device.hublot}", update_interval=UPDATE_INTERVAL, ) diff --git a/homeassistant/components/rituals_perfume_genie/config_flow.py b/homeassistant/components/rituals_perfume_genie/config_flow.py index f1f037941b3..f9a7f1cb6b8 100644 --- a/homeassistant/components/rituals_perfume_genie/config_flow.py +++ b/homeassistant/components/rituals_perfume_genie/config_flow.py @@ -1,5 +1,8 @@ """Config flow for Rituals Perfume Genie integration.""" +from __future__ import annotations + import logging +from typing import Any from aiohttp import ClientResponseError from pyrituals import Account, AuthenticationException @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" if user_input is None: return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA) @@ -47,12 +52,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(account.data[CONF_EMAIL]) + await self.async_set_unique_id(account.email) self._abort_if_unique_id_configured() return self.async_create_entry( - title=account.data[CONF_EMAIL], - data={ACCOUNT_HASH: account.data[ACCOUNT_HASH]}, + title=account.email, + data={ACCOUNT_HASH: account.account_hash}, ) return self.async_show_form( diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index bafdef9140c..21c570ffb93 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -1,9 +1,7 @@ """Constants for the Rituals Perfume Genie integration.""" DOMAIN = "rituals_perfume_genie" +ACCOUNT_HASH = "account_hash" + COORDINATORS = "coordinators" DEVICES = "devices" - -ACCOUNT_HASH = "account_hash" -HUBLOT = "hublot" -SENSORS = "sensors" diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index 19c3f3cd424..16c4e76686c 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -6,19 +6,12 @@ from pyrituals import Diffuser from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RitualsDataUpdateCoordinator -from .const import DOMAIN, HUBLOT, SENSORS +from .const import DOMAIN MANUFACTURER = "Rituals Cosmetics" MODEL = "The Perfume Genie" MODEL2 = "The Perfume Genie 2.0" -ATTRIBUTES = "attributes" -ROOMNAME = "roomnamec" -STATUS = "status" -VERSION = "versionc" - -AVAILABLE_STATE = 1 - class DiffuserEntity(CoordinatorEntity): """Representation of a diffuser entity.""" @@ -35,8 +28,8 @@ class DiffuserEntity(CoordinatorEntity): super().__init__(coordinator) self._diffuser = diffuser - hublot = self._diffuser.hub_data[HUBLOT] - hubname = self._diffuser.hub_data[ATTRIBUTES][ROOMNAME] + hublot = self._diffuser.hublot + hubname = self._diffuser.name self._attr_name = f"{hubname}{entity_suffix}" self._attr_unique_id = f"{hublot}{entity_suffix}" @@ -45,10 +38,10 @@ class DiffuserEntity(CoordinatorEntity): "identifiers": {(DOMAIN, hublot)}, "manufacturer": MANUFACTURER, "model": MODEL if diffuser.has_battery else MODEL2, - "sw_version": diffuser.hub_data[SENSORS][VERSION], + "sw_version": diffuser.version, } @property def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._diffuser.hub_data[STATUS] == AVAILABLE_STATE + return super().available and self._diffuser.is_online diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 2736b960751..2daa6e43873 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -3,7 +3,8 @@ "name": "Rituals Perfume Genie", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", - "requirements": ["pyrituals==0.0.4"], + "requirements": ["pyrituals==0.0.6"], "codeowners": ["@milanmeu"], + "quality_scale": "silver", "iot_class": "cloud_polling" } diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 2965371733b..7c957722384 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -13,16 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN, SENSORS +from .const import COORDINATORS, DEVICES, DOMAIN from .entity import DiffuserEntity -ID = "id" -PERFUME = "rfidc" -FILL = "fillc" - -PERFUME_NO_CARTRIDGE_ID = 19 -FILL_NO_CARTRIDGE_ID = 12 - BATTERY_SUFFIX = " Battery" PERFUME_SUFFIX = " Perfume" FILL_SUFFIX = " Fill" @@ -58,9 +51,12 @@ class DiffuserPerfumeSensor(DiffuserEntity): """Initialize the perfume sensor.""" super().__init__(diffuser, coordinator, PERFUME_SUFFIX) - self._attr_icon = "mdi:tag-text" - if diffuser.hub_data[SENSORS][PERFUME][ID] == PERFUME_NO_CARTRIDGE_ID: - self._attr_icon = "mdi:tag-remove" + @property + def icon(self) -> str: + """Return the perfume sensor icon.""" + if self._diffuser.has_cartridge: + return "mdi:tag-text" + return "mdi:tag-remove" @property def state(self) -> str: @@ -80,9 +76,9 @@ class DiffuserFillSensor(DiffuserEntity): @property def icon(self) -> str: """Return the fill sensor icon.""" - if self._diffuser.hub_data[SENSORS][FILL][ID] == FILL_NO_CARTRIDGE_ID: - return "mdi:beaker-question" - return "mdi:beaker" + if self._diffuser.has_cartridge: + return "mdi:beaker" + return "mdi:beaker-question" @property def state(self) -> str: diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index 924a38dfde8..180c144a358 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -43,14 +43,6 @@ class DiffuserSwitch(SwitchEntity, DiffuserEntity): super().__init__(diffuser, coordinator, "") self._attr_is_on = self._diffuser.is_on - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the device state attributes.""" - return { - "fan_speed": self._diffuser.perfume_amount, - "room_size": self._diffuser.room_size, - } - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._diffuser.turn_on() diff --git a/homeassistant/components/rituals_perfume_genie/translations/de.json b/homeassistant/components/rituals_perfume_genie/translations/de.json index 72f18702457..5edc9f60dd0 100644 --- a/homeassistant/components/rituals_perfume_genie/translations/de.json +++ b/homeassistant/components/rituals_perfume_genie/translations/de.json @@ -14,7 +14,7 @@ "email": "E-Mail", "password": "Passwort" }, - "title": "Verbinden Sie sich mit Ihrem Rituals-Konto" + "title": "Verbinden mit deinem Rituals-Konto" } } } diff --git a/homeassistant/components/rituals_perfume_genie/translations/hu.json b/homeassistant/components/rituals_perfume_genie/translations/hu.json index 4ecaf2ba0d0..4c3cdfeae86 100644 --- a/homeassistant/components/rituals_perfume_genie/translations/hu.json +++ b/homeassistant/components/rituals_perfume_genie/translations/hu.json @@ -13,7 +13,8 @@ "data": { "email": "E-mail", "password": "Jelsz\u00f3" - } + }, + "title": "Csatlakozzon Rituals-fi\u00f3kj\u00e1hoz" } } } diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index d85dae53303..9e4e7f3d588 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -262,7 +262,7 @@ class RMVDepartureData: elif journey["minutes"] < self._time_offset: continue - for attr in ["direction", "departure_time", "product", "minutes"]: + for attr in ("direction", "departure_time", "product", "minutes"): _nextdep[attr] = journey.get(attr, "") _nextdep["line"] = journey.get("number", "") diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index bc85915f39a..55da7484aba 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -1,11 +1,9 @@ """Support for Roku.""" from __future__ import annotations -from datetime import timedelta import logging -from rokuecp import Roku, RokuConnectionError, RokuError -from rokuecp.models import Device +from rokuecp import RokuConnectionError, RokuError from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN @@ -13,16 +11,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST 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.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.dt import utcnow from .const import DOMAIN +from .coordinator import RokuDataUpdateCoordinator CONFIG_SCHEMA = cv.deprecated(DOMAIN) PLATFORMS = [MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] -SCAN_INTERVAL = timedelta(seconds=15) _LOGGER = logging.getLogger(__name__) @@ -63,42 +58,3 @@ def roku_exception_handler(func): _LOGGER.error("Invalid response from API: %s", error) return handler - - -class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): - """Class to manage fetching Roku data.""" - - def __init__( - self, - hass: HomeAssistant, - *, - host: str, - ) -> None: - """Initialize global Roku data updater.""" - self.roku = Roku(host=host, session=async_get_clientsession(hass)) - - self.full_update_interval = timedelta(minutes=15) - self.last_full_update = None - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self) -> Device: - """Fetch data from Roku.""" - full_update = self.last_full_update is None or utcnow() >= ( - self.last_full_update + self.full_update_interval - ) - - try: - data = await self.roku.update(full_update=full_update) - - if full_update: - self.last_full_update = utcnow() - - return data - except RokuError as error: - raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 470dccbe37f..b9e93b4f008 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import DiscoveryInfoType from .const import DOMAIN @@ -111,7 +112,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() - async def async_step_ssdp(self, discovery_info: dict | None = None) -> FlowResult: + async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a flow initialized by discovery.""" host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname name = discovery_info[ATTR_UPNP_FRIENDLY_NAME] diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py index dc458c88cd0..1a1383dceb6 100644 --- a/homeassistant/components/roku/const.py +++ b/homeassistant/components/roku/const.py @@ -2,12 +2,7 @@ DOMAIN = "roku" # Attributes -ATTR_IDENTIFIERS = "identifiers" ATTR_KEYWORD = "keyword" -ATTR_MANUFACTURER = "manufacturer" -ATTR_MODEL = "model" -ATTR_SOFTWARE_VERSION = "sw_version" -ATTR_SUGGESTED_AREA = "suggested_area" # Default Values DEFAULT_PORT = 8060 diff --git a/homeassistant/components/roku/coordinator.py b/homeassistant/components/roku/coordinator.py new file mode 100644 index 00000000000..08766efa42d --- /dev/null +++ b/homeassistant/components/roku/coordinator.py @@ -0,0 +1,60 @@ +"""Coordinator for Roku.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from rokuecp import Roku, RokuError +from rokuecp.models import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utcnow + +from .const import DOMAIN + +SCAN_INTERVAL = timedelta(seconds=15) +_LOGGER = logging.getLogger(__name__) + + +class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]): + """Class to manage fetching Roku data.""" + + last_full_update: datetime | None + roku: Roku + + def __init__( + self, + hass: HomeAssistant, + *, + host: str, + ) -> None: + """Initialize global Roku data updater.""" + self.roku = Roku(host=host, session=async_get_clientsession(hass)) + + self.full_update_interval = timedelta(minutes=15) + self.last_full_update = None + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> Device: + """Fetch data from Roku.""" + full_update = self.last_full_update is None or utcnow() >= ( + self.last_full_update + self.full_update_interval + ) + + try: + data = await self.roku.update(full_update=full_update) + + if full_update: + self.last_full_update = utcnow() + + return data + except RokuError as error: + raise UpdateFailed(f"Invalid response from API: {error}") from error diff --git a/homeassistant/components/roku/entity.py b/homeassistant/components/roku/entity.py index aefc335e64d..5dc58d4b387 100644 --- a/homeassistant/components/roku/entity.py +++ b/homeassistant/components/roku/entity.py @@ -1,24 +1,25 @@ """Base Entity for Roku.""" from __future__ import annotations -from homeassistant.const import ATTR_NAME +from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, +) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RokuDataUpdateCoordinator -from .const import ( - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SOFTWARE_VERSION, - ATTR_SUGGESTED_AREA, - DOMAIN, -) +from .const import DOMAIN class RokuEntity(CoordinatorEntity): """Defines a base Roku entity.""" + coordinator: RokuDataUpdateCoordinator + def __init__( self, *, device_id: str, coordinator: RokuDataUpdateCoordinator ) -> None: @@ -34,9 +35,9 @@ class RokuEntity(CoordinatorEntity): return { ATTR_IDENTIFIERS: {(DOMAIN, self._device_id)}, - ATTR_NAME: self.name, + ATTR_NAME: self.coordinator.data.info.name, ATTR_MANUFACTURER: self.coordinator.data.info.brand, ATTR_MODEL: self.coordinator.data.info.model_name, - ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version, - ATTR_SUGGESTED_AREA: self.coordinator.data.info.device_location, + ATTR_SW_VERSION: self.coordinator.data.info.version, + "suggested_area": self.coordinator.data.info.device_location, } diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index dc0f2ff704c..bb9b4bfa37f 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -1,6 +1,7 @@ """Support for the Roku media player.""" from __future__ import annotations +import datetime as dt import logging import voluptuous as vol @@ -8,6 +9,7 @@ import voluptuous as vol from homeassistant.components.media_player import ( DEVICE_CLASS_RECEIVER, DEVICE_CLASS_TV, + BrowseMedia, MediaPlayerEntity, ) from homeassistant.components.media_player.const import ( @@ -37,9 +39,10 @@ from homeassistant.const import ( from homeassistant.helpers import entity_platform from homeassistant.helpers.network import is_internal_request -from . import RokuDataUpdateCoordinator, roku_exception_handler +from . import roku_exception_handler from .browse_media import build_item_response, library_payload from .const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH +from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity _LOGGER = logging.getLogger(__name__) @@ -63,7 +66,7 @@ SEARCH_SCHEMA = {vol.Required(ATTR_KEYWORD): str} async def async_setup_entry(hass, entry, async_add_entities): """Set up the Roku config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: RokuDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] unique_id = coordinator.data.info.serial_number async_add_entities([RokuMediaPlayer(unique_id, coordinator)], True) @@ -88,6 +91,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): self._attr_name = coordinator.data.info.name self._attr_unique_id = unique_id + self._attr_supported_features = SUPPORT_ROKU def _media_playback_trackable(self) -> bool: """Detect if we have enough media data to track playback.""" @@ -105,7 +109,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return DEVICE_CLASS_RECEIVER @property - def state(self) -> str: + def state(self) -> str | None: """Return the state of the device.""" if self.coordinator.data.state.standby: return STATE_STANDBY @@ -133,12 +137,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_ROKU - - @property - def media_content_type(self) -> str: + def media_content_type(self) -> str | None: """Content type of current playing media.""" if self.app_id is None or self.app_name in ("Power Saver", "Roku"): return None @@ -149,7 +148,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return MEDIA_TYPE_APP @property - def media_image_url(self) -> str: + def media_image_url(self) -> str | None: """Image url of current playing media.""" if self.app_id is None or self.app_name in ("Power Saver", "Roku"): return None @@ -157,7 +156,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return self.coordinator.roku.app_icon_url(self.app_id) @property - def app_name(self) -> str: + def app_name(self) -> str | None: """Name of the current running app.""" if self.coordinator.data.app is not None: return self.coordinator.data.app.name @@ -165,7 +164,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def app_id(self) -> str: + def app_id(self) -> str | None: """Return the ID of the current running app.""" if self.coordinator.data.app is not None: return self.coordinator.data.app.app_id @@ -173,7 +172,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def media_channel(self): + def media_channel(self) -> str | None: """Return the TV channel currently tuned.""" if self.app_id != "tvinput.dtv" or self.coordinator.data.channel is None: return None @@ -184,7 +183,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return self.coordinator.data.channel.number @property - def media_title(self): + def media_title(self) -> str | None: """Return the title of current playing media.""" if self.app_id != "tvinput.dtv" or self.coordinator.data.channel is None: return None @@ -195,7 +194,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def media_duration(self): + def media_duration(self) -> int | None: """Duration of current playing media in seconds.""" if self._media_playback_trackable(): return self.coordinator.data.media.duration @@ -203,7 +202,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def media_position(self): + def media_position(self) -> int | None: """Position of current playing media in seconds.""" if self._media_playback_trackable(): return self.coordinator.data.media.position @@ -211,7 +210,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def media_position_updated_at(self): + def media_position_updated_at(self) -> dt.datetime | None: """When was the position of the current playing media valid.""" if self._media_playback_trackable(): return self.coordinator.data.media.at @@ -219,7 +218,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return None @property - def source(self) -> str: + def source(self) -> str | None: """Return the current input source.""" if self.coordinator.data.app is not None: return self.coordinator.data.app.name @@ -237,8 +236,11 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): await self.coordinator.roku.search(keyword) async def async_get_browse_image( - self, media_content_type, media_content_id, media_image_id=None - ): + self, + media_content_type: str, + media_content_id: str, + media_image_id: str | None = None, + ) -> tuple[str | None, str | None]: """Fetch media browser image to serve via proxy.""" if media_content_type == MEDIA_TYPE_APP and media_content_id: image_url = self.coordinator.roku.app_icon_url(media_content_id) @@ -246,7 +248,11 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): return (None, None) - async def async_browse_media(self, media_content_type=None, media_content_id=None): + async def async_browse_media( + self, + media_content_type: str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: """Implement the websocket media browsing helper.""" is_internal = is_internal_request(self.hass) diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py index 28095311d81..8f0d39ed1d9 100644 --- a/homeassistant/components/roku/remote.py +++ b/homeassistant/components/roku/remote.py @@ -6,8 +6,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RokuDataUpdateCoordinator, roku_exception_handler +from . import roku_exception_handler from .const import DOMAIN +from .coordinator import RokuDataUpdateCoordinator from .entity import RokuEntity @@ -15,7 +16,7 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, -) -> bool: +) -> None: """Load Roku remote based on a config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id] unique_id = coordinator.data.info.serial_number diff --git a/homeassistant/components/roku/translations/de.json b/homeassistant/components/roku/translations/de.json index 77cd1b94b6e..ce8ec9e4595 100644 --- a/homeassistant/components/roku/translations/de.json +++ b/homeassistant/components/roku/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "unknown": "Unerwarteter Fehler" }, @@ -19,14 +19,14 @@ "one": "eins", "other": "andere" }, - "description": "M\u00f6chten Sie {name} einrichten?", + "description": "M\u00f6chtest du {name} einrichten?", "title": "Roku" }, "user": { "data": { "host": "Host" }, - "description": "Geben Sie Ihre Roku-Informationen ein." + "description": "Gib deine Roku-Informationen ein." } } } diff --git a/homeassistant/components/roku/translations/he.json b/homeassistant/components/roku/translations/he.json index 41d59c29fd8..12dc4bb482b 100644 --- a/homeassistant/components/roku/translations/he.json +++ b/homeassistant/components/roku/translations/he.json @@ -10,6 +10,14 @@ }, "flow_title": "{name}", "step": { + "ssdp_confirm": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "user": { "data": { "host": "\u05de\u05d0\u05e8\u05d7" diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 2f57ef954b6..8cee2a1ce61 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -212,7 +212,7 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): pos_x = pos_state.get("point", {}).get("x") pos_y = pos_state.get("point", {}).get("y") theta = pos_state.get("theta") - if all(item is not None for item in [pos_x, pos_y, theta]): + if all(item is not None for item in (pos_x, pos_y, theta)): position = f"({pos_x}, {pos_y}, {theta})" state_attrs[ATTR_POSITION] = position diff --git a/homeassistant/components/roomba/translations/de.json b/homeassistant/components/roomba/translations/de.json index 193469008e2..bfc98069881 100644 --- a/homeassistant/components/roomba/translations/de.json +++ b/homeassistant/components/roomba/translations/de.json @@ -34,7 +34,7 @@ "blid": "BLID", "host": "Host" }, - "description": "Es wurde kein Roomba oder Braava in Ihrem Netzwerk entdeckt. Die BLID ist der Teil des Ger\u00e4te-Hostnamens nach `iRobot-` oder `Roomba-`. Bitte folgen Sie den Schritten, die in der Dokumentation unter: {auth_help_url}", + "description": "Es wurde kein Roomba oder Braava in deinem Netzwerk entdeckt. Die BLID ist der Teil des Ger\u00e4te-Hostnamens nach `iRobot-` oder `Roomba-`. Bitte folge den Schritten in der Dokumentation unter: {auth_help_url}", "title": "Manuell mit dem Ger\u00e4t verbinden" }, "user": { @@ -45,7 +45,7 @@ "host": "Host", "password": "Passwort" }, - "description": "W\u00e4hlen Sie einen Roomba oder Braava aus.", + "description": "W\u00e4hle einen Roomba oder Braava aus.", "title": "Automatisch mit dem Ger\u00e4t verbinden" } } diff --git a/homeassistant/components/roomba/translations/he.json b/homeassistant/components/roomba/translations/he.json index e2d0c48b0b9..4520671eedb 100644 --- a/homeassistant/components/roomba/translations/he.json +++ b/homeassistant/components/roomba/translations/he.json @@ -34,7 +34,7 @@ "host": "\u05de\u05d0\u05e8\u05d7", "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05db\u05e8\u05d2\u05e2 \u05d0\u05d7\u05d6\u05d5\u05e8 \u05d4-BLID \u05d5\u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05d4\u05d5\u05d0 \u05ea\u05d4\u05dc\u05d9\u05da \u05d9\u05d3\u05e0\u05d9. \u05e0\u05d0 \u05d1\u05e6\u05e2 \u05d0\u05ea \u05d4\u05e9\u05dc\u05d1\u05d9\u05dd \u05d4\u05de\u05ea\u05d5\u05d0\u05e8\u05d9\u05dd \u05d1\u05ea\u05d9\u05e2\u05d5\u05d3 \u05d1\u05db\u05ea\u05d5\u05d1\u05ea: https://www.home-assistant.io/integrations/roomba/#retrieving-your-credentials" + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea Roomba \u05d0\u05d5 Braava." } } } diff --git a/homeassistant/components/roomba/translations/hu.json b/homeassistant/components/roomba/translations/hu.json index 51957ba8847..2f8d902f4fe 100644 --- a/homeassistant/components/roomba/translations/hu.json +++ b/homeassistant/components/roomba/translations/hu.json @@ -15,12 +15,18 @@ "data": { "host": "Hoszt" }, + "description": "V\u00e1lasszon egy Roomba vagy Braava k\u00e9sz\u00fcl\u00e9ket.", "title": "Automatikus csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" }, + "link": { + "description": "Nyomja meg \u00e9s tartsa lenyomva a(z) {name} Kezd\u0151lap (Home) gombot, am\u00edg az eszk\u00f6z hangot ad (kb. K\u00e9t m\u00e1sodperc), majd engedje el 30 m\u00e1sodpercen bel\u00fcl.", + "title": "Jelsz\u00f3 lek\u00e9r\u00e9se" + }, "link_manual": { "data": { "password": "Jelsz\u00f3" }, + "description": "A jelsz\u00f3t nem siker\u00fclt automatikusan lek\u00e9rni az eszk\u00f6zr\u0151l. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3ban ismertetett l\u00e9p\u00e9seket: {auth_help_url}", "title": "Jelsz\u00f3 megad\u00e1sa" }, "manual": { @@ -28,6 +34,7 @@ "blid": "BLID", "host": "Hoszt" }, + "description": "A h\u00e1l\u00f3zaton egyetlen Roomba vagy Braava sem ker\u00fclt el\u0151. A BLID az eszk\u00f6z hostnev\u00e9nek az `iRobot-` vagy `Roomba -` ut\u00e1ni r\u00e9sze. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3ban ismertetett l\u00e9p\u00e9seket: {auth_help_url}", "title": "Manu\u00e1lis csatlakoz\u00e1s az eszk\u00f6zh\u00f6z" }, "user": { diff --git a/homeassistant/components/roon/manifest.json b/homeassistant/components/roon/manifest.json index 354117e8fe4..f4864571735 100644 --- a/homeassistant/components/roon/manifest.json +++ b/homeassistant/components/roon/manifest.json @@ -3,7 +3,7 @@ "name": "RoonLabs music player", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/roon", - "requirements": ["roonapi==0.0.37"], + "requirements": ["roonapi==0.0.38"], "codeowners": ["@pavoni"], "iot_class": "local_push" } diff --git a/homeassistant/components/roon/translations/de.json b/homeassistant/components/roon/translations/de.json index cd4aae46adc..4eadf9c363a 100644 --- a/homeassistant/components/roon/translations/de.json +++ b/homeassistant/components/roon/translations/de.json @@ -9,14 +9,14 @@ }, "step": { "link": { - "description": "Sie m\u00fcssen den Home Assistant in Roon autorisieren. Nachdem Sie auf \"Submit\" geklickt haben, gehen Sie zur Roon Core-Anwendung, \u00f6ffnen Sie die Einstellungen und aktivieren Sie HomeAssistant auf der Registerkarte \"Extensions\".", + "description": "Du musst den Home Assistant in Roon autorisieren. Nachdem du auf \"Submit\" geklickt hast, gehe zur Roon Core-Anwendung, \u00f6ffne die Einstellungen und aktiviere HomeAssistant auf der Registerkarte \"Extensions\".", "title": "HomeAssistant in Roon autorisieren" }, "user": { "data": { "host": "Host" }, - "description": "Roon-Server konnte nicht gefunden werden, bitte geben Sie den Hostnamen oder die IP ein." + "description": "Roon-Server konnte nicht gefunden werden, bitte gib den Hostnamen oder die IP ein." } } } diff --git a/homeassistant/components/roon/translations/hu.json b/homeassistant/components/roon/translations/hu.json index f05fd838572..123027a8216 100644 --- a/homeassistant/components/roon/translations/hu.json +++ b/homeassistant/components/roon/translations/hu.json @@ -8,6 +8,9 @@ "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "link": { + "title": "Enged\u00e9lyezze a HomeAssistant alkalmaz\u00e1st Roon-ban" + }, "user": { "data": { "host": "Hoszt" diff --git a/homeassistant/components/roon/translations/nl.json b/homeassistant/components/roon/translations/nl.json index 452436d0e05..df8fa80b4dd 100644 --- a/homeassistant/components/roon/translations/nl.json +++ b/homeassistant/components/roon/translations/nl.json @@ -16,7 +16,7 @@ "data": { "host": "Host" }, - "description": "Voer de hostnaam of het IP-adres van uw Roon-server in." + "description": "Kon de Roon-server niet vinden, voer de hostnaam of het IP-adres in." } } } diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py index 13f8fffb8d1..35d8c0ae2c0 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -1,4 +1,5 @@ """Support for Rova garbage calendar.""" +from __future__ import annotations from datetime import datetime, timedelta import logging @@ -7,7 +8,11 @@ from requests.exceptions import ConnectTimeout, HTTPError from rova.rova import Rova import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, @@ -24,13 +29,28 @@ CONF_HOUSE_NUMBER_SUFFIX = "house_number_suffix" UPDATE_DELAY = timedelta(hours=12) SCAN_INTERVAL = timedelta(hours=12) -# Supported sensor types: -# Key: [json_key, name, icon] -SENSOR_TYPES = { - "bio": ["gft", "Biowaste", "mdi:recycle"], - "paper": ["papier", "Paper", "mdi:recycle"], - "plastic": ["pmd", "PET", "mdi:recycle"], - "residual": ["restafval", "Residual", "mdi:recycle"], + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "bio": SensorEntityDescription( + key="gft", + name="bio", + icon="mdi:recycle", + ), + "paper": SensorEntityDescription( + key="papier", + name="paper", + icon="mdi:recycle", + ), + "plastic": SensorEntityDescription( + key="pmd", + name="plastic", + icon="mdi:recycle", + ), + "residual": SensorEntityDescription( + key="restafval", + name="residual", + icon="mdi:recycle", + ), } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -71,53 +91,32 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data_service = RovaData(api) # Create a new sensor for each garbage type. - entities = [] - for sensor_key in config[CONF_MONITORED_CONDITIONS]: - sensor = RovaSensor(platform_name, sensor_key, data_service) - entities.append(sensor) - + entities = [ + RovaSensor(platform_name, SENSOR_TYPES[sensor_key], data_service) + for sensor_key in config[CONF_MONITORED_CONDITIONS] + ] add_entities(entities, True) class RovaSensor(SensorEntity): """Representation of a Rova sensor.""" - def __init__(self, platform_name, sensor_key, data_service): + def __init__( + self, platform_name, description: SensorEntityDescription, data_service + ): """Initialize the sensor.""" - self.sensor_key = sensor_key - self.platform_name = platform_name + self.entity_description = description self.data_service = data_service - self._state = None - - self._json_key = SENSOR_TYPES[self.sensor_key][0] - - @property - def name(self): - """Return the name.""" - return f"{self.platform_name}_{self.sensor_key}" - - @property - def icon(self): - """Return the sensor icon.""" - return SENSOR_TYPES[self.sensor_key][2] - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICE_CLASS_TIMESTAMP - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + self._attr_name = f"{platform_name}_{description.name}" + self._attr_device_class = DEVICE_CLASS_TIMESTAMP def update(self): """Get the latest data from the sensor and update the state.""" self.data_service.update() - pickup_date = self.data_service.data.get(self._json_key) + pickup_date = self.data_service.data.get(self.entity_description.key) if pickup_date is not None: - self._state = pickup_date.isoformat() + self._attr_state = pickup_date.isoformat() class RovaData: diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py index 2d7edd83fed..070e861b3c9 100644 --- a/homeassistant/components/rpi_camera/camera.py +++ b/homeassistant/components/rpi_camera/camera.py @@ -26,9 +26,10 @@ _LOGGER = logging.getLogger(__name__) def kill_raspistill(*args): """Kill any previously running raspistill process..""" - subprocess.Popen( + with subprocess.Popen( ["killall", "raspistill"], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT - ) + ): + pass def setup_platform(hass, config, add_entities, discovery_info=None): @@ -116,7 +117,10 @@ class RaspberryCamera(Camera): cmd_args.append("-a") cmd_args.append(str(device_info[CONF_OVERLAY_TIMESTAMP])) - subprocess.Popen(cmd_args, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + with subprocess.Popen( + cmd_args, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT + ): + pass def camera_image(self): """Return raspistill image response.""" diff --git a/homeassistant/components/rpi_power/translations/de.json b/homeassistant/components/rpi_power/translations/de.json index 1a00c87b985..ada0c101a52 100644 --- a/homeassistant/components/rpi_power/translations/de.json +++ b/homeassistant/components/rpi_power/translations/de.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "no_devices_found": "Die f\u00fcr diese Komponente ben\u00f6tigte Systemklasse konnte nicht gefunden werden. Stellen Sie sicher, dass Ihr Kernel aktuell ist und die Hardware unterst\u00fctzt wird", + "no_devices_found": "Die f\u00fcr diese Komponente ben\u00f6tigte Systemklasse konnte nicht gefunden werden. Stelle sicher, dass dein Kernel aktuell ist und die Hardware unterst\u00fctzt wird", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { "confirm": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, diff --git a/homeassistant/components/rpi_power/translations/he.json b/homeassistant/components/rpi_power/translations/he.json index a18f311e43a..a4e4e475087 100644 --- a/homeassistant/components/rpi_power/translations/he.json +++ b/homeassistant/components/rpi_power/translations/he.json @@ -8,5 +8,6 @@ "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } - } + }, + "title": "\u05d1\u05d5\u05d3\u05e7 \u05d0\u05e1\u05e4\u05e7\u05ea \u05d4\u05d7\u05e9\u05de\u05dc \u05e9\u05dc \u05e8\u05e1\u05e4\u05d1\u05e8\u05d9 \u05e4\u05d0\u05d9" } \ No newline at end of file diff --git a/homeassistant/components/rpi_power/translations/hu.json b/homeassistant/components/rpi_power/translations/hu.json index 2d1c0811286..feb1687037f 100644 --- a/homeassistant/components/rpi_power/translations/hu.json +++ b/homeassistant/components/rpi_power/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "Nem tal\u00e1lja az ehhez a komponenshez sz\u00fcks\u00e9ges rendszerszintet, gy\u0151z\u0151dj\u00f6n meg arr\u00f3l, hogy a rendszermag (kernel) friss, \u00e9s a hardver t\u00e1mogatott", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "step": { diff --git a/homeassistant/components/rpi_rf/manifest.json b/homeassistant/components/rpi_rf/manifest.json index e8806710724..022c84eb13f 100644 --- a/homeassistant/components/rpi_rf/manifest.json +++ b/homeassistant/components/rpi_rf/manifest.json @@ -2,7 +2,7 @@ "domain": "rpi_rf", "name": "Raspberry Pi RF", "documentation": "https://www.home-assistant.io/integrations/rpi_rf", - "requirements": ["rpi-rf==0.9.7"], + "requirements": ["rpi-rf==0.9.7", "RPi.GPIO==0.7.1a4"], "codeowners": [], "iot_class": "assumed_state" } diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py index c9871c8f6b5..4ea9c27b82e 100644 --- a/homeassistant/components/rss_feed_template/__init__.py +++ b/homeassistant/components/rss_feed_template/__init__.py @@ -40,7 +40,7 @@ CONFIG_SCHEMA = vol.Schema( def setup(hass, config): """Set up the RSS feed template component.""" for (feeduri, feedconfig) in config[DOMAIN].items(): - url = "/api/rss_template/%s" % feeduri + url = f"/api/rss_template/{feeduri}" requires_auth = feedconfig.get("requires_api_password") diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 09b513c3830..773c340d7b9 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -1,6 +1,8 @@ """The Samsung TV integration.""" +from functools import partial import socket +import getmac import voluptuous as vol from homeassistant import config_entries @@ -140,13 +142,19 @@ async def _async_create_bridge_with_updated_data(hass, entry): bridge = _async_get_device_bridge({**entry.data, **updated_data}) - if not entry.data.get(CONF_MAC) and bridge.method == METHOD_WEBSOCKET: + mac = entry.data.get(CONF_MAC) + if not mac and bridge.method == METHOD_WEBSOCKET: if info: mac = mac_from_device_info(info) else: mac = await hass.async_add_executor_job(bridge.mac_from_device) - if mac: - updated_data[CONF_MAC] = mac + + if not mac: + mac = await hass.async_add_executor_job( + partial(getmac.get_mac_address, ip=host) + ) + if mac: + updated_data[CONF_MAC] = mac if updated_data: data = {**entry.data, **updated_data} diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 1cdd63acd3c..095d3339428 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -209,8 +209,8 @@ class SamsungTVLegacyBridge(SamsungTVBridge): except AccessDenied: LOGGER.debug("Working but denied config: %s", config) return RESULT_AUTH_MISSING - except UnhandledResponse: - LOGGER.debug("Working but unsupported config: %s", config) + except UnhandledResponse as err: + LOGGER.debug("Working but unsupported config: %s, error: %s", config, err) return RESULT_NOT_SUPPORTED except (ConnectionClosed, OSError) as err: LOGGER.debug("Failing config: %s, error: %s", config, err) @@ -289,8 +289,10 @@ class SamsungTVWSBridge(SamsungTVBridge): config[CONF_TOKEN] = "*****" LOGGER.debug("Working config: %s", config) return RESULT_SUCCESS - except WebSocketException: - LOGGER.debug("Working but unsupported config: %s", config) + except WebSocketException as err: + LOGGER.debug( + "Working but unsupported config: %s, error: %s", config, err + ) result = RESULT_NOT_SUPPORTED except (OSError, ConnectionFailure) as err: LOGGER.debug("Failing config: %s, error: %s", config, err) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 76128a1f1dd..da13d0fe70c 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -2,6 +2,7 @@ import socket from urllib.parse import urlparse +import getmac import voluptuous as vol from homeassistant import config_entries, data_entry_flow @@ -134,6 +135,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hass, self._bridge, self._host ) if not info: + if not _method: + LOGGER.debug( + "Samsung host %s is not supported by either %s or %s methods", + self._host, + METHOD_LEGACY, + METHOD_WEBSOCKET, + ) + raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) return False dev_info = info.get("device", {}) device_type = dev_info.get("type") @@ -146,6 +155,8 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._udn = _strip_uuid(dev_info.get("udn", info["id"])) if mac := mac_from_device_info(info): self._mac = mac + elif mac := getmac.get_mac_address(ip=self._host): + self._mac = mac self._device_info = info return True diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 133baccf4fb..36481b43756 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -3,6 +3,7 @@ "name": "Samsung Smart TV", "documentation": "https://www.home-assistant.io/integrations/samsungtv", "requirements": [ + "getmac==0.8.2", "samsungctl[websocket]==0.7.1", "samsungtvws==1.6.0", "wakeonlan==2.0.1" diff --git a/homeassistant/components/samsungtv/translations/cs.json b/homeassistant/components/samsungtv/translations/cs.json index 4453c7d227e..d45c9247b4e 100644 --- a/homeassistant/components/samsungtv/translations/cs.json +++ b/homeassistant/components/samsungtv/translations/cs.json @@ -5,9 +5,15 @@ "already_in_progress": "Konfigurace ji\u017e prob\u00edh\u00e1", "auth_missing": "Home Assistant nem\u00e1 opr\u00e1vn\u011bn\u00ed k p\u0159ipojen\u00ed k t\u00e9to televizi Samsung. Zkontrolujte nastaven\u00ed sv\u00e9 televize a povolte Home Assistant.", "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", - "not_supported": "Tato televize Samsung nen\u00ed aktu\u00e1ln\u011b podporov\u00e1na." + "id_missing": "Toto za\u0159\u00edzen\u00ed Samsung nem\u00e1 s\u00e9riov\u00e9 \u010d\u00edslo.", + "not_supported": "Tato televize Samsung nen\u00ed aktu\u00e1ln\u011b podporov\u00e1na.", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9", + "unknown": "Neo\u010dek\u00e1van\u00e1 chyba" }, - "flow_title": "Samsung TV: {model}", + "error": { + "auth_missing": "Home Assistant nem\u00e1 opr\u00e1vn\u011bn\u00ed k p\u0159ipojen\u00ed k t\u00e9to televizi Samsung. Zkontrolujte nastaven\u00ed sv\u00e9 televize a povolte Home Assistant." + }, + "flow_title": "{device}", "step": { "confirm": { "description": "Chcete nastavit {device}? Pokud jste Home Assistant doposud nikdy nep\u0159ipojili, m\u011bla by se v\u00e1m na televizi zobrazit \u017e\u00e1dost o povolen\u00ed.", diff --git a/homeassistant/components/samsungtv/translations/de.json b/homeassistant/components/samsungtv/translations/de.json index 710443bc24f..f59004a5dab 100644 --- a/homeassistant/components/samsungtv/translations/de.json +++ b/homeassistant/components/samsungtv/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieser Samsung TV ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "auth_missing": "Home Assistant ist nicht berechtigt, eine Verbindung zu diesem Samsung TV herzustellen. \u00dcberpr\u00fcfe den Ger\u00e4teverbindungsmanager in den Einstellungen deines Fernsehger\u00e4ts, um Home Assistant zu autorisieren.", "cannot_connect": "Verbindung fehlgeschlagen", @@ -20,7 +20,7 @@ "title": "Samsung TV" }, "reauth_confirm": { - "description": "Akzeptieren Sie nach dem Absenden die Meldung auf {device}, das eine Autorisierung innerhalb von 30 Sekunden anfordert." + "description": "Akzeptiere nach dem Absenden die Meldung auf {device}, das eine Autorisierung innerhalb von 30 Sekunden anfordert." }, "user": { "data": { diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json index 15a529c94b2..5a20992d8e5 100644 --- a/homeassistant/components/samsungtv/translations/fr.json +++ b/homeassistant/components/samsungtv/translations/fr.json @@ -5,7 +5,13 @@ "already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.", "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. Veuillez v\u00e9rifier les param\u00e8tres de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant.", "cannot_connect": "\u00c9chec de connexion", - "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge." + "id_missing": "Cet appareil Samsung n'a pas de num\u00e9ro de s\u00e9rie.", + "not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "unknown": "Erreur inattendue" + }, + "error": { + "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. V\u00e9rifiez les param\u00e8tres du Gestionnaire de p\u00e9riph\u00e9riques externes de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant." }, "flow_title": "Samsung TV: {model}", "step": { @@ -13,6 +19,9 @@ "description": "Voulez vous installer la TV {model} Samsung? Si vous n'avez jamais connect\u00e9 Home Assistant avant, vous devriez voir une fen\u00eatre contextuelle sur votre t\u00e9l\u00e9viseur demandant une authentification. Les configurations manuelles de ce t\u00e9l\u00e9viseur seront \u00e9cras\u00e9es.", "title": "TV Samsung" }, + "reauth_confirm": { + "description": "Apr\u00e8s avoir soumis, acceptez la fen\u00eatre contextuelle sur {device} demandant l'autorisation dans les 30 secondes." + }, "user": { "data": { "host": "Nom d'h\u00f4te ou adresse IP", diff --git a/homeassistant/components/samsungtv/translations/hu.json b/homeassistant/components/samsungtv/translations/hu.json index a720c5932ed..f0aa85433a1 100644 --- a/homeassistant/components/samsungtv/translations/hu.json +++ b/homeassistant/components/samsungtv/translations/hu.json @@ -5,6 +5,7 @@ "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "auth_missing": "A Home Assistant nem jogosult csatlakozni ehhez a Samsung TV-hez. Ellen\u0151rizd a TV be\u00e1ll\u00edt\u00e1sait a Home Assistant enged\u00e9lyez\u00e9s\u00e9hez.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "id_missing": "Ennek a Samsung eszk\u00f6znek nincs sorsz\u00e1ma.", "not_supported": "Ez a Samsung k\u00e9sz\u00fcl\u00e9k jelenleg nem t\u00e1mogatott.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" @@ -18,6 +19,9 @@ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a(z) {device} k\u00e9sz\u00fcl\u00e9ket? Ha kor\u00e1bban m\u00e9g csatlakoztattad a Home Assistantet, akkor meg kell jelennie egy felugr\u00f3 ablaknak a TV k\u00e9perny\u0151j\u00e9n, ami j\u00f3v\u00e1hagy\u00e1sra v\u00e1r.", "title": "Samsung TV" }, + "reauth_confirm": { + "description": "A bek\u00fcld\u00e9s ut\u00e1n fogadja el a(z) {device} felugr\u00f3 ablakot, amely 30 m\u00e1sodpercen bel\u00fcl enged\u00e9lyt k\u00e9r." + }, "user": { "data": { "host": "Hoszt", diff --git a/homeassistant/components/samsungtv/translations/id.json b/homeassistant/components/samsungtv/translations/id.json index 7d0f5982a65..0b8bbe60150 100644 --- a/homeassistant/components/samsungtv/translations/id.json +++ b/homeassistant/components/samsungtv/translations/id.json @@ -5,7 +5,12 @@ "already_in_progress": "Alur konfigurasi sedang berlangsung", "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa setelan TV Anda untuk mengotorisasi Home Assistant.", "cannot_connect": "Gagal terhubung", - "not_supported": "Perangkat TV Samsung ini saat ini tidak didukung." + "not_supported": "Perangkat TV Samsung ini saat ini tidak didukung.", + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "auth_missing": "Home Assistant tidak diizinkan untuk tersambung ke TV Samsung ini. Periksa setelan TV Anda untuk mengotorisasi Home Assistant." }, "flow_title": "TV Samsung: {model}", "step": { diff --git a/homeassistant/components/screenlogic/translations/de.json b/homeassistant/components/screenlogic/translations/de.json index 84f425be218..30e3ee0c726 100644 --- a/homeassistant/components/screenlogic/translations/de.json +++ b/homeassistant/components/screenlogic/translations/de.json @@ -18,7 +18,7 @@ }, "gateway_select": { "data": { - "selected_gateway": "" + "selected_gateway": "Gateway" }, "description": "Die folgenden ScreenLogic-Gateways wurden erkannt. Bitte w\u00e4hle eines aus, um es zu konfigurieren oder w\u00e4hle ein ScreenLogic-Gateway zum manuellen Konfigurieren.", "title": "ScreenLogic" diff --git a/homeassistant/components/screenlogic/translations/hu.json b/homeassistant/components/screenlogic/translations/hu.json index f46ab499a29..3efb8ec6e60 100644 --- a/homeassistant/components/screenlogic/translations/hu.json +++ b/homeassistant/components/screenlogic/translations/hu.json @@ -20,6 +20,7 @@ "data": { "selected_gateway": "Gateway" }, + "description": "A k\u00f6vetkez\u0151 ScreenLogic \u00e1tj\u00e1r\u00f3kat fedezt\u00e9k fel. V\u00e1lasszon egyet a konfigur\u00e1l\u00e1shoz, vagy v\u00e1lassza a ScreenLogic \u00e1tj\u00e1r\u00f3 k\u00e9zi konfigur\u00e1l\u00e1s\u00e1t.", "title": "ScreenLogic" } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 41d5e697cf1..483b4065be2 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -401,7 +401,12 @@ class ScriptEntity(ToggleEntity): # Prepare tracing the execution of the script's sequence script_trace.set_trace(trace_get()) with trace_path("sequence"): - return await self.script.async_run(variables, context) + this = None + state = self.hass.states.get(self.entity_id) + if state: + this = state.as_dict() + script_vars = {"this": this, **(variables or {})} + return await self.script.async_run(script_vars, context) async def async_turn_off(self, **kwargs): """Stop running the script. diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py index b1ebe88dc91..6993b7181e1 100644 --- a/homeassistant/components/script/config.py +++ b/homeassistant/components/script/config.py @@ -71,10 +71,10 @@ async def async_validate_config_item(hass, config, full_config=None): config = SCRIPT_ENTITY_SCHEMA(config) config[CONF_SEQUENCE] = await asyncio.gather( - *[ + *( async_validate_action_config(hass, action) for action in config[CONF_SEQUENCE] - ] + ) ) return config diff --git a/homeassistant/components/script/translations/he.json b/homeassistant/components/script/translations/he.json index 7c2b808c58d..26d8f95b91c 100644 --- a/homeassistant/components/script/translations/he.json +++ b/homeassistant/components/script/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05e1\u05e7\u05e8\u05d9\u05e4\u05d8" diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 93da95bc550..fc13b8ca098 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -1,4 +1,6 @@ """The Search integration.""" +from __future__ import annotations + from collections import defaultdict, deque import logging @@ -71,7 +73,7 @@ class Searcher: hass: HomeAssistant, device_reg: device_registry.DeviceRegistry, entity_reg: entity_registry.EntityRegistry, - entity_sources: "dict[str, dict[str, str]]", + entity_sources: dict[str, dict[str, str]], ) -> None: """Search results.""" self.hass = hass diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index 4ec8c46ef05..d5c70c76cd0 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -1,6 +1,7 @@ """Component to allow selecting an option from a list as platforms.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -14,7 +15,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -57,9 +58,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class SelectEntityDescription(EntityDescription): + """A class that describes select entities.""" + + class SelectEntity(Entity): """Representation of a Select entity.""" + entity_description: SelectEntityDescription _attr_current_option: str | None _attr_options: list[str] _attr_state: None = None diff --git a/homeassistant/components/select/translations/fr.json b/homeassistant/components/select/translations/fr.json new file mode 100644 index 00000000000..5248940b2c4 --- /dev/null +++ b/homeassistant/components/select/translations/fr.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "action_type": { + "select_option": "Modifier l'option {entity_name}" + }, + "condition_type": { + "selected_option": "Option actuellement s\u00e9lectionn\u00e9e {entity_name}" + }, + "trigger_type": { + "current_option_changed": "Modification de l\u2019option {entity_name}" + } + }, + "title": "S\u00e9lectionner" +} \ No newline at end of file diff --git a/homeassistant/components/select/translations/he.json b/homeassistant/components/select/translations/he.json new file mode 100644 index 00000000000..7f2ff684474 --- /dev/null +++ b/homeassistant/components/select/translations/he.json @@ -0,0 +1,3 @@ +{ + "title": "\u05d1\u05d7\u05e8" +} \ No newline at end of file diff --git a/homeassistant/components/select/translations/id.json b/homeassistant/components/select/translations/id.json new file mode 100644 index 00000000000..f042ddf3218 --- /dev/null +++ b/homeassistant/components/select/translations/id.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "action_type": { + "select_option": "Ubah opsi {entity_name}" + }, + "condition_type": { + "selected_option": "Opsi terpilih {entity_name}" + }, + "trigger_type": { + "current_option_changed": "Opsi {entity_name} berubah" + } + }, + "title": "Pilihan" +} \ No newline at end of file diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index d779870d37d..5a352969c3b 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -1,14 +1,18 @@ """Support for monitoring a Sense energy sensor.""" +import datetime + from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( ATTR_ATTRIBUTION, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_POWER, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, - VOLT, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.util.dt as dt_util from .const import ( ACTIVE_NAME, @@ -96,8 +100,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for i in range(len(data.active_voltage)): devices.append(SenseVoltageSensor(data, i, sense_monitor_id)) - for type_id in TRENDS_SENSOR_TYPES: - typ = TRENDS_SENSOR_TYPES[type_id] + for type_id, typ in TRENDS_SENSOR_TYPES.items(): for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type @@ -175,7 +178,7 @@ class SenseActiveSensor(SensorEntity): class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" - _attr_unit_of_measurement = VOLT + _attr_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON _attr_should_poll = False @@ -219,6 +222,8 @@ class SenseVoltageSensor(SensorEntity): class SenseTrendsSensor(SensorEntity): """Implementation of a Sense energy sensor.""" + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_MEASUREMENT _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR _attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} _attr_icon = ICON @@ -253,6 +258,13 @@ class SenseTrendsSensor(SensorEntity): """Return if entity is available.""" return self._had_any_update and self._coordinator.last_update_success + @property + def last_reset(self) -> datetime.datetime: + """Return the time when the sensor was last reset, if any.""" + if self._sensor_type == "DAY": + return dt_util.start_of_local_day() + return None + @callback def _async_update(self): """Track if we had an update so we do not report zero data.""" diff --git a/homeassistant/components/sense/translations/de.json b/homeassistant/components/sense/translations/de.json index 9d4845ece79..df36684c8b4 100644 --- a/homeassistant/components/sense/translations/de.json +++ b/homeassistant/components/sense/translations/de.json @@ -11,10 +11,10 @@ "step": { "user": { "data": { - "email": "E-Mail-Adresse", + "email": "E-Mail", "password": "Passwort" }, - "title": "Stellen Sie eine Verbindung zu Ihrem Sense Energy Monitor her" + "title": "Stelle eine Verbindung zu deinem Sense Energy Monitor her" } } } diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 6ba00baae77..379301b0fa7 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_DISPLAY_OPTIONS, CONF_NAME, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) @@ -24,9 +25,9 @@ CONF_IS_HAT_ATTACHED = "is_hat_attached" MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) SENSOR_TYPES = { - "temperature": ["temperature", TEMP_CELSIUS], - "humidity": ["humidity", PERCENTAGE], - "pressure": ["pressure", "mb"], + "temperature": ["temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], + "humidity": ["humidity", PERCENTAGE, None], + "pressure": ["pressure", "mb", None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -77,6 +78,7 @@ class SenseHatSensor(SensorEntity): self._unit_of_measurement = SENSOR_TYPES[sensor_types][1] self.type = sensor_types self._state = None + self._attr_device_class = SENSOR_TYPES[sensor_types][2] @property def name(self): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 8fac4e50b3e..e36640b1c1d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from datetime import datetime, timedelta import logging from typing import Any, Final, cast, final @@ -31,7 +32,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -95,21 +96,38 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class SensorEntityDescription(EntityDescription): + """A class that describes sensor entities.""" + + state_class: str | None = None + last_reset: datetime | None = None + + class SensorEntity(Entity): """Base class for sensor entities.""" - _attr_state_class: str | None = None - _attr_last_reset: datetime | None = None + entity_description: SensorEntityDescription + _attr_state_class: str | None + _attr_last_reset: datetime | None @property def state_class(self) -> str | None: """Return the state class of this entity, from STATE_CLASSES, if any.""" - return self._attr_state_class + if hasattr(self, "_attr_state_class"): + return self._attr_state_class + if hasattr(self, "entity_description"): + return self.entity_description.state_class + return None @property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if any.""" - return self._attr_last_reset + if hasattr(self, "_attr_last_reset"): + return self._attr_last_reset + if hasattr(self, "entity_description"): + return self.entity_description.last_reset + return None @property def capability_attributes(self) -> Mapping[str, Any] | None: diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index bbd49814076..afcfe2f228d 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -23,6 +23,7 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, + PERCENTAGE, POWER_KILO_WATT, POWER_WATT, PRESSURE_BAR, @@ -40,11 +41,11 @@ import homeassistant.util.dt as dt_util import homeassistant.util.pressure as pressure_util import homeassistant.util.temperature as temperature_util -from . import DOMAIN +from . import ATTR_LAST_RESET, DOMAIN _LOGGER = logging.getLogger(__name__) -DEVICE_CLASS_STATISTICS = { +DEVICE_CLASS_OR_UNIT_STATISTICS = { DEVICE_CLASS_BATTERY: {"mean", "min", "max"}, DEVICE_CLASS_ENERGY: {"sum"}, DEVICE_CLASS_HUMIDITY: {"mean", "min", "max"}, @@ -52,6 +53,7 @@ DEVICE_CLASS_STATISTICS = { DEVICE_CLASS_POWER: {"mean", "min", "max"}, DEVICE_CLASS_PRESSURE: {"mean", "min", "max"}, DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, + PERCENTAGE: {"mean", "min", "max"}, } # Normalized units which will be stored in the statistics table @@ -102,13 +104,19 @@ def _get_entities(hass: HomeAssistant) -> list[tuple[str, str]]: entity_ids = [] for state in all_sensors: - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - state_class = state.attributes.get(ATTR_STATE_CLASS) - if not state_class or state_class != STATE_CLASS_MEASUREMENT: + if state.attributes.get(ATTR_STATE_CLASS) != STATE_CLASS_MEASUREMENT: continue - if not device_class or device_class not in DEVICE_CLASS_STATISTICS: - continue - entity_ids.append((state.entity_id, device_class)) + + if ( + key := state.attributes.get(ATTR_DEVICE_CLASS) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS: + entity_ids.append((state.entity_id, key)) + + if ( + key := state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + ) in DEVICE_CLASS_OR_UNIT_STATISTICS: + entity_ids.append((state.entity_id, key)) + return entity_ids @@ -158,12 +166,12 @@ def _time_weighted_average( def _normalize_states( - entity_history: list[State], device_class: str, entity_id: str + entity_history: list[State], key: str, entity_id: str ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" unit = None - if device_class not in UNIT_CONVERSIONS: + if key not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are fstates = [ (float(el.state), el) for el in entity_history if _is_number(el.state) @@ -182,15 +190,15 @@ def _normalize_states( fstate = float(state.state) unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) # Exclude unsupported units from statistics - if unit not in UNIT_CONVERSIONS[device_class]: + if unit not in UNIT_CONVERSIONS[key]: if entity_id not in WARN_UNSUPPORTED_UNIT: WARN_UNSUPPORTED_UNIT.add(entity_id) _LOGGER.warning("%s has unknown unit %s", entity_id, unit) continue - fstates.append((UNIT_CONVERSIONS[device_class][unit](fstate), state)) + fstates.append((UNIT_CONVERSIONS[key][unit](fstate), state)) - return DEVICE_CLASS_UNITS[device_class], fstates + return DEVICE_CLASS_UNITS[key], fstates def compile_statistics( @@ -209,14 +217,14 @@ def compile_statistics( hass, start - datetime.timedelta.resolution, end, [i[0] for i in entities] ) - for entity_id, device_class in entities: - wanted_statistics = DEVICE_CLASS_STATISTICS[device_class] + for entity_id, key in entities: + wanted_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] if entity_id not in history_list: continue entity_history = history_list[entity_id] - unit, fstates = _normalize_states(entity_history, device_class, entity_id) + unit, fstates = _normalize_states(entity_history, key, entity_id) if not fstates: continue @@ -244,7 +252,7 @@ def compile_statistics( last_reset = old_last_reset = None new_state = old_state = None _sum = 0 - last_stats = statistics.get_last_statistics(hass, 1, entity_id) # type: ignore + last_stats = statistics.get_last_statistics(hass, 1, entity_id) if entity_id in last_stats: # We have compiled history for this sensor before, use that as a starting point last_reset = old_last_reset = last_stats[entity_id][0]["last_reset"] @@ -280,3 +288,36 @@ def compile_statistics( result[entity_id]["stat"] = stat return result + + +def list_statistic_ids(hass: HomeAssistant, statistic_type: str | None = None) -> dict: + """Return statistic_ids and meta data.""" + entities = _get_entities(hass) + + statistic_ids = {} + + for entity_id, key in entities: + provided_statistics = DEVICE_CLASS_OR_UNIT_STATISTICS[key] + + if statistic_type is not None and statistic_type not in provided_statistics: + continue + + state = hass.states.get(entity_id) + assert state + + if "sum" in provided_statistics and ATTR_LAST_RESET not in state.attributes: + continue + + native_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + + if key not in UNIT_CONVERSIONS: + statistic_ids[entity_id] = native_unit + continue + + if native_unit not in UNIT_CONVERSIONS[key]: + continue + + statistics_unit = DEVICE_CLASS_UNITS[key] + statistic_ids[entity_id] = statistics_unit + + return statistic_ids diff --git a/homeassistant/components/sensor/translations/he.json b/homeassistant/components/sensor/translations/he.json index 2fec6a8d81f..8efc4e37126 100644 --- a/homeassistant/components/sensor/translations/he.json +++ b/homeassistant/components/sensor/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05d7\u05d9\u05d9\u05e9\u05df" diff --git a/homeassistant/components/sensor/translations/hu.json b/homeassistant/components/sensor/translations/hu.json index beb1c56b305..9b1c9bece82 100644 --- a/homeassistant/components/sensor/translations/hu.json +++ b/homeassistant/components/sensor/translations/hu.json @@ -4,21 +4,28 @@ "is_battery_level": "{entity_name} aktu\u00e1lis akku szintje", "is_carbon_dioxide": "Jelenlegi {entity_name} sz\u00e9n-dioxid koncentr\u00e1ci\u00f3 szint", "is_carbon_monoxide": "Jelenlegi {entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3 szint", + "is_current": "Jelenlegi {entity_name} \u00e1ram", + "is_energy": "A jelenlegi {entity_name} energia", "is_humidity": "{entity_name} aktu\u00e1lis p\u00e1ratartalma", "is_illuminance": "{entity_name} aktu\u00e1lis megvil\u00e1g\u00edt\u00e1sa", "is_power": "{entity_name} aktu\u00e1lis teljes\u00edtm\u00e9nye", + "is_power_factor": "A jelenlegi {entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151", "is_pressure": "{entity_name} aktu\u00e1lis nyom\u00e1sa", "is_signal_strength": "{entity_name} aktu\u00e1lis jeler\u0151ss\u00e9ge", "is_temperature": "{entity_name} aktu\u00e1lis h\u0151m\u00e9rs\u00e9klete", - "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke" + "is_value": "{entity_name} aktu\u00e1lis \u00e9rt\u00e9ke", + "is_voltage": "A jelenlegi {entity_name} fesz\u00fclts\u00e9g" }, "trigger_type": { "battery_level": "{entity_name} akku szintje v\u00e1ltozik", "carbon_dioxide": "{entity_name} sz\u00e9n-dioxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", "carbon_monoxide": "{entity_name} sz\u00e9n-monoxid koncentr\u00e1ci\u00f3ja megv\u00e1ltozik", + "current": "{entity_name} aktu\u00e1lis v\u00e1ltoz\u00e1sai", + "energy": "{entity_name} energiav\u00e1ltoz\u00e1sa", "humidity": "{entity_name} p\u00e1ratartalma v\u00e1ltozik", "illuminance": "{entity_name} megvil\u00e1g\u00edt\u00e1sa v\u00e1ltozik", "power": "{entity_name} teljes\u00edtm\u00e9nye v\u00e1ltozik", + "power_factor": "{entity_name} teljes\u00edtm\u00e9nyt\u00e9nyez\u0151 megv\u00e1ltozik", "pressure": "{entity_name} nyom\u00e1sa v\u00e1ltozik", "signal_strength": "{entity_name} jeler\u0151ss\u00e9ge v\u00e1ltozik", "temperature": "{entity_name} h\u0151m\u00e9rs\u00e9klete v\u00e1ltozik", diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 37941a0bcaa..c34bc2b350a 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -78,7 +78,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } - # pylint: disable=abstract-class-instantiated sentry_sdk.init( dsn=entry.data[CONF_DSN], environment=entry.options.get(CONF_ENVIRONMENT), diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 0b37e6a849a..d6ddf61f19a 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.1.0"], + "requirements": ["sentry-sdk==1.3.0"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/sentry/translations/hu.json b/homeassistant/components/sentry/translations/hu.json index 055c8817177..79188df18b1 100644 --- a/homeassistant/components/sentry/translations/hu.json +++ b/homeassistant/components/sentry/translations/hu.json @@ -9,9 +9,25 @@ }, "step": { "user": { + "data": { + "dsn": "DSN" + }, "description": "Add meg a Sentry DSN-t", "title": "Sentry" } } + }, + "options": { + "step": { + "init": { + "data": { + "environment": "A k\u00f6rnyezet v\u00e1laszthat\u00f3 neve.", + "event_custom_components": "Esem\u00e9nyek k\u00fcld\u00e9se egy\u00e9ni \u00f6sszetev\u0151kb\u0151l", + "event_handled": "K\u00fcldj\u00f6n kezelt esem\u00e9nyeket", + "event_third_party_packages": "K\u00fcldj\u00f6n esem\u00e9nyeket harmadik f\u00e9l csomagjaib\u00f3l", + "tracing": "Enged\u00e9lyezze a teljes\u00edtm\u00e9nyk\u00f6vet\u00e9st" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py index 01490c39297..16ed0e14d9a 100644 --- a/homeassistant/components/sharkiq/update_coordinator.py +++ b/homeassistant/components/sharkiq/update_coordinator.py @@ -69,7 +69,7 @@ class SharkIqUpdateCoordinator(DataUpdateCoordinator): _LOGGER.debug("Updating sharkiq data") online_vacs = (self.shark_vacs[dsn] for dsn in self.online_dsns) - await asyncio.gather(*[self._async_update_vacuum(v) for v in online_vacs]) + await asyncio.gather(*(self._async_update_vacuum(v) for v in online_vacs)) except ( SharkIqAuthError, SharkIqNotAuthedError, diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index dc0deef1b82..a0dfd3388b4 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -60,8 +60,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if rendered_args == args: # No template used. default behavior - # pylint: disable=no-member - create_process = asyncio.subprocess.create_subprocess_shell( + create_process = asyncio.create_subprocess_shell( cmd, stdin=None, stdout=asyncio.subprocess.PIPE, @@ -72,8 +71,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # (which uses shell=False) for security shlexed_cmd = [prog] + shlex.split(rendered_args) - # pylint: disable=no-member - create_process = asyncio.subprocess.create_subprocess_exec( + create_process = asyncio.create_subprocess_exec( *shlexed_cmd, stdin=None, stdout=asyncio.subprocess.PIPE, @@ -92,6 +90,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if process: with suppress(TypeError): process.kill() + # https://bugs.python.org/issue43884 + # pylint: disable=protected-access + process._transport.close() # type: ignore[attr-defined] del process return diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 425ff11399b..48e27203288 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -1,7 +1,10 @@ """The Shelly integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging +from typing import Any, Final, cast import aioshelly import async_timeout @@ -15,10 +18,11 @@ from homeassistant.const import ( CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import ( AIOSHELLY_DEVICE_TIMEOUT_SEC, @@ -43,19 +47,19 @@ from .const import ( ) from .utils import get_coap_context, get_device_name, get_device_sleep_period -PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"] -SLEEPING_PLATFORMS = ["binary_sensor", "sensor"] -_LOGGER = logging.getLogger(__name__) +PLATFORMS: Final = ["binary_sensor", "cover", "light", "sensor", "switch"] +SLEEPING_PLATFORMS: Final = ["binary_sensor", "sensor"] +_LOGGER: Final = logging.getLogger(__name__) -COAP_SCHEMA = vol.Schema( +COAP_SCHEMA: Final = vol.Schema( { vol.Optional(CONF_COAP_PORT, default=DEFAULT_COAP_PORT): cv.port, } ) -CONFIG_SCHEMA = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA) +CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: COAP_SCHEMA}, extra=vol.ALLOW_EXTRA) -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Shelly component.""" hass.data[DOMAIN] = {DATA_CONFIG_ENTRY: {}} @@ -113,7 +117,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sleep_period = entry.data.get("sleep_period") @callback - def _async_device_online(_): + def _async_device_online(_: Any) -> None: _LOGGER.debug("Device %s is online, resuming setup", entry.title) hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][DEVICE] = None @@ -153,7 +157,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_device_setup( hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device -): +) -> None: """Set up a device that is online.""" device_wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id][ COAP @@ -174,9 +178,11 @@ async def async_device_setup( class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): """Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass, entry, device: aioshelly.Device): + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: aioshelly.Device + ) -> None: """Initialize the Shelly device wrapper.""" - self.device_id = None + self.device_id: str | None = None sleep_period = entry.data["sleep_period"] if sleep_period: @@ -205,7 +211,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @callback - def _async_device_updates_handler(self): + def _async_device_updates_handler(self) -> None: """Handle device updates.""" if not self.device.initialized: return @@ -258,7 +264,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.name, ) - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data.""" if self.entry.data.get("sleep_period"): # Sleeping device, no point polling it, just mark it unavailable @@ -267,21 +273,21 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): _LOGGER.debug("Polling Shelly Device - %s", self.name) try: async with async_timeout.timeout(POLLING_TIMEOUT_SEC): - return await self.device.update() + await self.device.update() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @property - def model(self): + def model(self) -> str: """Model of the device.""" - return self.entry.data["model"] + return cast(str, self.entry.data["model"]) @property - def mac(self): + def mac(self) -> str: """Mac address of the device.""" - return self.entry.unique_id + return cast(str, self.entry.unique_id) - async def async_setup(self): + async def async_setup(self) -> None: """Set up the wrapper.""" dev_reg = await device_registry.async_get_registry(self.hass) sw_version = self.device.settings["fw"] if self.device.initialized else "" @@ -298,7 +304,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device_id = entry.id self.device.subscribe_updates(self.async_set_updated_data) - def shutdown(self): + def shutdown(self) -> None: """Shutdown the wrapper.""" if self.device: self.device.shutdown() @@ -306,7 +312,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device = None @callback - def _handle_ha_stop(self, _): + def _handle_ha_stop(self, _event: Event) -> None: """Handle Home Assistant stopping.""" _LOGGER.debug("Stopping ShellyDeviceWrapper for %s", self.name) self.shutdown() @@ -315,7 +321,7 @@ class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator): class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): """Rest Wrapper for a Shelly device with Home Assistant specific functions.""" - def __init__(self, hass, device: aioshelly.Device): + def __init__(self, hass: HomeAssistant, device: aioshelly.Device) -> None: """Initialize the Shelly device wrapper.""" if ( device.settings["device"]["type"] @@ -335,22 +341,22 @@ class ShellyDeviceRestWrapper(update_coordinator.DataUpdateCoordinator): ) self.device = device - async def _async_update_data(self): + async def _async_update_data(self) -> None: """Fetch data.""" try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): _LOGGER.debug("REST update for %s", self.name) - return await self.device.update_status() + await self.device.update_status() except OSError as err: raise update_coordinator.UpdateFailed("Error fetching data") from err @property - def mac(self): + def mac(self) -> str: """Mac address of the device.""" - return self.device.settings["device"]["mac"] + return cast(str, self.device.settings["device"]["mac"]) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" device = hass.data[DOMAIN][DATA_CONFIG_ENTRY][entry.entry_id].get(DEVICE) if device is not None: @@ -370,3 +376,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): hass.data[DOMAIN][DATA_CONFIG_ENTRY].pop(entry.entry_id) return unload_ok + + +def get_device_wrapper( + hass: HomeAssistant, device_id: str +) -> ShellyDeviceWrapper | None: + """Get a Shelly device wrapper for the given device id.""" + if not hass.data.get(DOMAIN): + return None + + for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: + wrapper: ShellyDeviceWrapper | None = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ + config_entry + ].get(COAP) + + if wrapper and wrapper.device_id == device_id: + return wrapper + + return None diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 385b3b30c36..dd1b3a9d66d 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -1,4 +1,8 @@ """Binary sensor for Shelly.""" +from __future__ import annotations + +from typing import Final + from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_GAS, @@ -12,6 +16,9 @@ from homeassistant.components.binary_sensor import ( STATE_ON, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import ( BlockAttributeDescription, @@ -24,7 +31,7 @@ from .entity import ( ) from .utils import is_momentary_input -SENSORS = { +SENSORS: Final = { ("device", "overtemp"): BlockAttributeDescription( name="Overheating", device_class=DEVICE_CLASS_PROBLEM ), @@ -83,7 +90,7 @@ SENSORS = { ), } -REST_SENSORS = { +REST_SENSORS: Final = { "cloud": RestAttributeDescription( name="Cloud", value=lambda status, _: status["cloud"]["connected"], @@ -103,7 +110,11 @@ REST_SENSORS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up sensors for device.""" if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( @@ -130,7 +141,7 @@ class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity): """Shelly binary sensor entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor state is on.""" return bool(self.attribute_value) @@ -139,7 +150,7 @@ class ShellyRestBinarySensor(ShellyRestAttributeEntity, BinarySensorEntity): """Shelly REST binary sensor entity.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if REST sensor state is on.""" return bool(self.attribute_value) @@ -150,7 +161,7 @@ class ShellySleepingBinarySensor( """Represent a shelly sleeping binary sensor.""" @property - def is_on(self): + def is_on(self) -> bool: """Return true if sensor state is on.""" if self.block is not None: return bool(self.attribute_value) diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py index 5bf8277066c..c4ddbc0b0aa 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Shelly integration.""" +from __future__ import annotations + import asyncio import logging +from typing import Any, Dict, Final, cast import aiohttp import aioshelly @@ -14,19 +17,23 @@ from homeassistant.const import ( CONF_USERNAME, HTTP_UNAUTHORIZED, ) +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import DiscoveryInfoType from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, DOMAIN from .utils import get_coap_context, get_device_sleep_period -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) +HOST_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str}) -HTTP_CONNECT_ERRORS = (asyncio.TimeoutError, aiohttp.ClientError) +HTTP_CONNECT_ERRORS: Final = (asyncio.TimeoutError, aiohttp.ClientError) -async def validate_input(hass: core.HomeAssistant, host, data): +async def validate_input( + hass: core.HomeAssistant, host: str, data: dict[str, Any] +) -> dict[str, Any]: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -60,15 +67,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - host = None - info = None - device_info = None + host: str = "" + info: dict[str, Any] = {} + device_info: dict[str, Any] = {} - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: - host = user_input[CONF_HOST] + host: str = user_input[CONF_HOST] try: info = await self._async_get_info(host) except HTTP_CONNECT_ERRORS: @@ -106,9 +115,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=HOST_SCHEMA, errors=errors ) - async def async_step_credentials(self, user_input=None): + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the credentials step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: try: device_info = await validate_input(self.hass, self.host, user_input) @@ -146,7 +157,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="credentials", data_schema=schema, errors=errors ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: """Handle zeroconf discovery.""" try: self.info = info = await self._async_get_info(discovery_info["host"]) @@ -173,9 +186,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_confirm_discovery() - async def async_step_confirm_discovery(self, user_input=None): + async def async_step_confirm_discovery( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle discovery confirm.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry( title=self.device_info["title"] or self.device_info["hostname"], @@ -199,10 +214,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_get_info(self, host): + async def _async_get_info(self, host: str) -> dict[str, Any]: """Get info from shelly device.""" async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - return await aioshelly.get_info( - aiohttp_client.async_get_clientsession(self.hass), - host, + return cast( + Dict[str, Any], + await aioshelly.get_info( + aiohttp_client.async_get_clientsession(self.hass), + host, + ), ) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 119ae478bb7..49e33dfd5e1 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -1,34 +1,37 @@ """Constants for the Shelly integration.""" +from __future__ import annotations -COAP = "coap" -DATA_CONFIG_ENTRY = "config_entry" -DEVICE = "device" -DOMAIN = "shelly" -REST = "rest" +from typing import Final -CONF_COAP_PORT = "coap_port" -DEFAULT_COAP_PORT = 5683 +COAP: Final = "coap" +DATA_CONFIG_ENTRY: Final = "config_entry" +DEVICE: Final = "device" +DOMAIN: Final = "shelly" +REST: Final = "rest" + +CONF_COAP_PORT: Final = "coap_port" +DEFAULT_COAP_PORT: Final = 5683 # Used in "_async_update_data" as timeout for polling data from devices. -POLLING_TIMEOUT_SEC = 18 +POLLING_TIMEOUT_SEC: Final = 18 # Refresh interval for REST sensors -REST_SENSORS_UPDATE_INTERVAL = 60 +REST_SENSORS_UPDATE_INTERVAL: Final = 60 # Timeout used for aioshelly calls -AIOSHELLY_DEVICE_TIMEOUT_SEC = 10 +AIOSHELLY_DEVICE_TIMEOUT_SEC: Final = 10 # Multiplier used to calculate the "update_interval" for sleeping devices. -SLEEP_PERIOD_MULTIPLIER = 1.2 +SLEEP_PERIOD_MULTIPLIER: Final = 1.2 # Multiplier used to calculate the "update_interval" for non-sleeping devices. -UPDATE_PERIOD_MULTIPLIER = 2.2 +UPDATE_PERIOD_MULTIPLIER: Final = 2.2 # Shelly Air - Maximum work hours before lamp replacement -SHAIR_MAX_WORK_HOURS = 9000 +SHAIR_MAX_WORK_HOURS: Final = 9000 # Map Shelly input events -INPUTS_EVENTS_DICT = { +INPUTS_EVENTS_DICT: Final = { "S": "single", "SS": "double", "SSS": "triple", @@ -38,28 +41,20 @@ INPUTS_EVENTS_DICT = { } # List of battery devices that maintain a permanent WiFi connection -BATTERY_DEVICES_WITH_PERMANENT_CONNECTION = ["SHMOS-01"] +BATTERY_DEVICES_WITH_PERMANENT_CONNECTION: Final = ["SHMOS-01"] -EVENT_SHELLY_CLICK = "shelly.click" +EVENT_SHELLY_CLICK: Final = "shelly.click" -ATTR_CLICK_TYPE = "click_type" -ATTR_CHANNEL = "channel" -ATTR_DEVICE = "device" -CONF_SUBTYPE = "subtype" +ATTR_CLICK_TYPE: Final = "click_type" +ATTR_CHANNEL: Final = "channel" +ATTR_DEVICE: Final = "device" +CONF_SUBTYPE: Final = "subtype" -BASIC_INPUTS_EVENTS_TYPES = { - "single", - "long", -} +BASIC_INPUTS_EVENTS_TYPES: Final = {"single", "long"} -SHBTN_INPUTS_EVENTS_TYPES = { - "single", - "double", - "triple", - "long", -} +SHBTN_INPUTS_EVENTS_TYPES: Final = {"single", "double", "triple", "long"} -SUPPORTED_INPUTS_EVENTS_TYPES = SHIX3_1_INPUTS_EVENTS_TYPES = { +SUPPORTED_INPUTS_EVENTS_TYPES: Final = { "single", "double", "triple", @@ -68,23 +63,20 @@ SUPPORTED_INPUTS_EVENTS_TYPES = SHIX3_1_INPUTS_EVENTS_TYPES = { "long_single", } -INPUTS_EVENTS_SUBTYPES = { - "button": 1, - "button1": 1, - "button2": 2, - "button3": 3, -} +SHIX3_1_INPUTS_EVENTS_TYPES = SUPPORTED_INPUTS_EVENTS_TYPES -SHBTN_MODELS = ["SHBTN-1", "SHBTN-2"] +INPUTS_EVENTS_SUBTYPES: Final = {"button": 1, "button1": 1, "button2": 2, "button3": 3} -STANDARD_RGB_EFFECTS = { +SHBTN_MODELS: Final = ["SHBTN-1", "SHBTN-2"] + +STANDARD_RGB_EFFECTS: Final = { 0: "Off", 1: "Meteor Shower", 2: "Gradual Change", 3: "Flash", } -SHBLB_1_RGB_EFFECTS = { +SHBLB_1_RGB_EFFECTS: Final = { 0: "Off", 1: "Meteor Shower", 2: "Gradual Change", @@ -95,8 +87,11 @@ SHBLB_1_RGB_EFFECTS = { } # Kelvin value for colorTemp -KELVIN_MAX_VALUE = 6500 -KELVIN_MIN_VALUE_WHITE = 2700 -KELVIN_MIN_VALUE_COLOR = 3000 +KELVIN_MAX_VALUE: Final = 6500 +KELVIN_MIN_VALUE_WHITE: Final = 2700 +KELVIN_MIN_VALUE_COLOR: Final = 3000 -UPTIME_DEVIATION = 5 +UPTIME_DEVIATION: Final = 5 + +LAST_RESET_UPTIME: Final = "uptime" +LAST_RESET_NEVER: Final = "never" diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index dc2dba654f3..73b8b1baae3 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -1,4 +1,8 @@ """Cover for Shelly.""" +from __future__ import annotations + +from typing import Any, cast + from aioshelly import Block from homeassistant.components.cover import ( @@ -10,14 +14,20 @@ from homeassistant.components.cover import ( SUPPORT_STOP, CoverEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ShellyDeviceWrapper from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN from .entity import ShellyBlockEntity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up cover for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] blocks = [block for block in wrapper.device.blocks if block.type == "roller"] @@ -36,72 +46,72 @@ class ShellyCover(ShellyBlockEntity, CoverEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) - self.control_result = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + self.control_result: dict[str, Any] | None = None + self._supported_features: int = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP if self.wrapper.device.settings["rollers"][0]["positioning"]: self._supported_features |= SUPPORT_SET_POSITION @property - def is_closed(self): + def is_closed(self) -> bool: """If cover is closed.""" if self.control_result: - return self.control_result["current_pos"] == 0 + return cast(bool, self.control_result["current_pos"] == 0) - return self.block.rollerPos == 0 + return cast(bool, self.block.rollerPos == 0) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Position of the cover.""" if self.control_result: - return self.control_result["current_pos"] + return cast(int, self.control_result["current_pos"]) - return self.block.rollerPos + return cast(int, self.block.rollerPos) @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing.""" if self.control_result: - return self.control_result["state"] == "close" + return cast(bool, self.control_result["state"] == "close") - return self.block.roller == "close" + return cast(bool, self.block.roller == "close") @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening.""" if self.control_result: - return self.control_result["state"] == "open" + return cast(bool, self.control_result["state"] == "open") - return self.block.roller == "open" + return cast(bool, self.block.roller == "open") @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return self._supported_features - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" self.control_result = await self.set_state(go="close") self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" self.control_result = await self.set_state(go="open") self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.control_result = await self.set_state( go="to_pos", roller_pos=kwargs[ATTR_POSITION] ) self.async_write_ha_state() - async def async_stop_cover(self, **_kwargs): + async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" self.control_result = await self.set_state(go="stop") self.async_write_ha_state() @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py index e767f49bcbb..bcb909555a9 100644 --- a/homeassistant/components/shelly/device_trigger.py +++ b/homeassistant/components/shelly/device_trigger.py @@ -1,6 +1,8 @@ """Provides device triggers for Shelly.""" from __future__ import annotations +from typing import Any, Final + import voluptuous as vol from homeassistant.components.automation import AutomationActionType @@ -20,6 +22,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType +from . import get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -31,9 +34,9 @@ from .const import ( SHBTN_MODELS, SUPPORTED_INPUTS_EVENTS_TYPES, ) -from .utils import get_device_wrapper, get_input_triggers +from .utils import get_input_triggers -TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( +TRIGGER_SCHEMA: Final = DEVICE_TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): vol.In(SUPPORTED_INPUTS_EVENTS_TYPES), vol.Required(CONF_SUBTYPE): vol.In(INPUTS_EVENTS_SUBTYPES), @@ -41,7 +44,9 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -async def async_validate_trigger_config(hass, config): +async def async_validate_trigger_config( + hass: HomeAssistant, config: dict[str, Any] +) -> dict[str, Any]: """Validate config.""" config = TRIGGER_SCHEMA(config) @@ -62,7 +67,9 @@ async def async_validate_trigger_config(hass, config): ) -async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: """List device triggers for Shelly devices.""" triggers = [] diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 744272ccf91..a1ce2e671d1 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -4,31 +4,39 @@ from __future__ import annotations import asyncio from dataclasses import dataclass import logging -from typing import Any, Callable +from typing import Any, Callable, Final, cast import aioshelly import async_timeout from homeassistant.components.sensor import ATTR_STATE_CLASS -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( device_registry, entity, entity_registry, update_coordinator, ) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import StateType from . import ShellyDeviceRestWrapper, ShellyDeviceWrapper from .const import AIOSHELLY_DEVICE_TIMEOUT_SEC, COAP, DATA_CONFIG_ENTRY, DOMAIN, REST from .utils import async_remove_shelly_entity, get_entity_name -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) async def async_setup_entry_attribute_entities( - hass, config_entry, async_add_entities, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for attributes.""" wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id @@ -45,8 +53,12 @@ async def async_setup_entry_attribute_entities( async def async_setup_block_attribute_entities( - hass, async_add_entities, wrapper, sensors, sensor_class -): + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + wrapper: ShellyDeviceWrapper, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for block attributes.""" blocks = [] @@ -82,8 +94,13 @@ async def async_setup_block_attribute_entities( async def async_restore_block_attribute_entities( - hass, config_entry, async_add_entities, wrapper, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + wrapper: ShellyDeviceWrapper, + sensors: dict[tuple[str, str], BlockAttributeDescription], + sensor_class: Callable, +) -> None: """Restore block attributes entities.""" entities = [] @@ -117,8 +134,12 @@ async def async_restore_block_attribute_entities( async def async_setup_entry_rest( - hass, config_entry, async_add_entities, sensors, sensor_class -): + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + sensors: dict[str, RestAttributeDescription], + sensor_class: Callable, +) -> None: """Set up entities for REST sensors.""" wrapper: ShellyDeviceRestWrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][ config_entry.entry_id @@ -158,6 +179,7 @@ class BlockAttributeDescription: # Callable (settings, block), return true if entity should be removed removal_condition: Callable[[dict, aioshelly.Block], bool] | None = None extra_state_attributes: Callable[[aioshelly.Block], dict | None] | None = None + last_reset: str | None = None @dataclass @@ -177,53 +199,53 @@ class RestAttributeDescription: class ShellyBlockEntity(entity.Entity): """Helper class to represent a block.""" - def __init__(self, wrapper: ShellyDeviceWrapper, block): + def __init__(self, wrapper: ShellyDeviceWrapper, block: aioshelly.Block) -> None: """Initialize Shelly entity.""" self.wrapper = wrapper self.block = block - self._name: str | None = get_entity_name(wrapper.device, block) + self._name = get_entity_name(wrapper.device, block) @property - def name(self): + def name(self) -> str: """Name of entity.""" return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """If device should be polled.""" return False @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return { "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} } @property - def available(self): + def available(self) -> bool: """Available.""" return self.wrapper.last_update_success @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return f"{self.wrapper.mac}-{self.block.description}" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.wrapper.async_add_listener(self._update_callback)) - async def async_update(self): + async def async_update(self) -> None: """Update entity with latest info.""" await self.wrapper.async_request_refresh() @callback - def _update_callback(self): + def _update_callback(self) -> None: """Handle device update.""" self.async_write_ha_state() - async def set_state(self, **kwargs): + async def set_state(self, **kwargs: Any) -> Any: """Set block state (HTTP request).""" _LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs) try: @@ -261,16 +283,17 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): unit = unit(block.info(attribute)) self._unit: None | str | Callable[[dict], str] = unit - self._unique_id: None | str = f"{super().unique_id}-{self.attribute}" + self._unique_id: str = f"{super().unique_id}-{self.attribute}" self._name = get_entity_name(wrapper.device, block, self.description.name) + self._last_value: str | None = None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return self._unique_id @property - def name(self): + def name(self) -> str: """Name of sensor.""" return self._name @@ -280,27 +303,27 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return self.description.default_enabled @property - def attribute_value(self): + def attribute_value(self) -> StateType: """Value of sensor.""" value = getattr(self.block, self.attribute) if value is None: return None - return self.description.value(value) + return cast(StateType, self.description.value(value)) @property - def device_class(self): + def device_class(self) -> str | None: """Device class of sensor.""" return self.description.device_class @property - def icon(self): + def icon(self) -> str | None: """Icon of sensor.""" return self.description.icon @property - def available(self): + def available(self) -> bool: """Available.""" available = super().available @@ -310,7 +333,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity): return self.description.available(self.block) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.description.extra_state_attributes is None: return None @@ -336,12 +359,12 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): self._last_value = None @property - def name(self): + def name(self) -> str: """Name of sensor.""" return self._name @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return { "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)} @@ -353,35 +376,36 @@ class ShellyRestAttributeEntity(update_coordinator.CoordinatorEntity): return self.description.default_enabled @property - def available(self): + def available(self) -> bool: """Available.""" return self.wrapper.last_update_success @property - def attribute_value(self): + def attribute_value(self) -> StateType: """Value of sensor.""" - self._last_value = self.description.value( - self.wrapper.device.status, self._last_value - ) + if callable(self.description.value): + self._last_value = self.description.value( + self.wrapper.device.status, self._last_value + ) return self._last_value @property - def device_class(self): + def device_class(self) -> str | None: """Device class of sensor.""" return self.description.device_class @property - def icon(self): + def icon(self) -> str | None: """Icon of sensor.""" return self.description.icon @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID of entity.""" return f"{self.wrapper.mac}-{self.attribute}" @property - def extra_state_attributes(self) -> dict | None: + def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" if self.description.extra_state_attributes is None: return None @@ -400,11 +424,11 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti attribute: str, description: BlockAttributeDescription, entry: entity_registry.RegistryEntry | None = None, - sensors: set | None = None, + sensors: dict[tuple[str, str], BlockAttributeDescription] | None = None, ) -> None: """Initialize the sleeping sensor.""" self.sensors = sensors - self.last_state = None + self.last_state: StateType = None self.wrapper = wrapper self.attribute = attribute self.block = block @@ -421,9 +445,9 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti ) elif entry is not None: self._unique_id = entry.unique_id - self._name = entry.original_name + self._name = cast(str, entry.original_name) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -434,7 +458,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity, RestoreEnti self.description.state_class = last_state.attributes.get(ATTR_STATE_CLASS) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Handle device update.""" if ( self.block is not None diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 8314650d548..047a105a30f 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio import logging -from typing import Any +from typing import Any, Final, cast from aioshelly import Block import async_timeout @@ -23,7 +23,9 @@ from homeassistant.components.light import ( LightEntity, brightness_supported, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import ( color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin, @@ -44,10 +46,14 @@ from .const import ( from .entity import ShellyBlockEntity from .utils import async_remove_shelly_entity -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up lights for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] @@ -78,12 +84,12 @@ class ShellyLight(ShellyBlockEntity, LightEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize light.""" super().__init__(wrapper, block) - self.control_result = None - self.mode_result = None - self._supported_color_modes = set() - self._supported_features = 0 - self._min_kelvin = KELVIN_MIN_VALUE_WHITE - self._max_kelvin = KELVIN_MAX_VALUE + self.control_result: dict[str, Any] | None = None + self.mode_result: dict[str, Any] | None = None + self._supported_color_modes: set[str] = set() + self._supported_features: int = 0 + self._min_kelvin: int = KELVIN_MIN_VALUE_WHITE + self._max_kelvin: int = KELVIN_MAX_VALUE if hasattr(block, "red") and hasattr(block, "green") and hasattr(block, "blue"): self._min_kelvin = KELVIN_MIN_VALUE_COLOR @@ -113,18 +119,18 @@ class ShellyLight(ShellyBlockEntity, LightEntity): def is_on(self) -> bool: """If light is on.""" if self.control_result: - return self.control_result["ison"] + return cast(bool, self.control_result["ison"]) - return self.block.output + return bool(self.block.output) @property - def mode(self) -> str | None: + def mode(self) -> str: """Return the color mode of the light.""" if self.mode_result: - return self.mode_result["mode"] + return cast(str, self.mode_result["mode"]) if hasattr(self.block, "mode"): - return self.block.mode + return cast(str, self.block.mode) if ( hasattr(self.block, "red") @@ -136,7 +142,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return "white" @property - def brightness(self) -> int | None: + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" if self.mode == "color": if self.control_result: @@ -152,7 +158,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return round(255 * brightness_pct / 100) @property - def color_mode(self) -> str | None: + def color_mode(self) -> str: """Return the color mode of the light.""" if self.mode == "color": if hasattr(self.block, "white"): @@ -191,7 +197,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return (*self.rgb_color, white) @property - def color_temp(self) -> int | None: + def color_temp(self) -> int: """Return the CT color value in mireds.""" if self.control_result: color_temp = self.control_result["temp"] @@ -244,7 +250,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return STANDARD_RGB_EFFECTS[effect_index] - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on light.""" if self.block.type == "relay": self.control_result = await self.set_state(turn="on") @@ -304,12 +310,12 @@ class ShellyLight(ShellyBlockEntity, LightEntity): self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off light.""" self.control_result = await self.set_state(turn="off") self.async_write_ha_state() - async def set_light_mode(self, set_mode): + async def set_light_mode(self, set_mode: str | None) -> bool: """Change device mode color/white if mode has changed.""" if set_mode is None or self.mode == set_mode: return True @@ -331,7 +337,7 @@ class ShellyLight(ShellyBlockEntity, LightEntity): return True @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control & mode result that overrides state.""" self.control_result = None self.mode_result = None diff --git a/homeassistant/components/shelly/logbook.py b/homeassistant/components/shelly/logbook.py index 78a5c279a93..deac3b5c05b 100644 --- a/homeassistant/components/shelly/logbook.py +++ b/homeassistant/components/shelly/logbook.py @@ -1,8 +1,13 @@ """Describe Shelly logbook events.""" +from __future__ import annotations + +from typing import Callable from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import EventType +from . import get_device_wrapper from .const import ( ATTR_CHANNEL, ATTR_CLICK_TYPE, @@ -10,18 +15,21 @@ from .const import ( DOMAIN, EVENT_SHELLY_CLICK, ) -from .utils import get_device_name, get_device_wrapper +from .utils import get_device_name @callback -def async_describe_events(hass, async_describe_event): +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[EventType], dict]], None], +) -> None: """Describe logbook events.""" @callback - def async_describe_shelly_click_event(event): + def async_describe_shelly_click_event(event: EventType) -> dict[str, str]: """Describe shelly.click logbook event.""" wrapper = get_device_wrapper(hass, event.data[ATTR_DEVICE_ID]) - if wrapper: + if wrapper and wrapper.device.initialized: device_name = get_device_name(wrapper.device) else: device_name = event.data[ATTR_DEVICE] diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index a42f38f8a1b..56e4f63bc75 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1,19 +1,29 @@ """Sensor for Shelly.""" +from __future__ import annotations + +from datetime import datetime +from typing import Final, cast + from homeassistant.components import sensor from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEGREE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - VOLT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import dt -from .const import SHAIR_MAX_WORK_HOURS +from .const import LAST_RESET_NEVER, LAST_RESET_UPTIME, SHAIR_MAX_WORK_HOURS from .entity import ( BlockAttributeDescription, RestAttributeDescription, @@ -25,7 +35,7 @@ from .entity import ( ) from .utils import get_device_uptime, temperature_unit -SENSORS = { +SENSORS: Final = { ("device", "battery"): BlockAttributeDescription( name="Battery", unit=PERCENTAGE, @@ -43,7 +53,7 @@ SENSORS = { ), ("emeter", "current"): BlockAttributeDescription( name="Current", - unit=ELECTRICAL_CURRENT_AMPERE, + unit=ELECTRIC_CURRENT_AMPERE, value=lambda value: value, device_class=sensor.DEVICE_CLASS_CURRENT, state_class=sensor.STATE_CLASS_MEASUREMENT, @@ -72,7 +82,7 @@ SENSORS = { ), ("emeter", "voltage"): BlockAttributeDescription( name="Voltage", - unit=VOLT, + unit=ELECTRIC_POTENTIAL_VOLT, value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, @@ -104,6 +114,7 @@ SENSORS = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("emeter", "energy"): BlockAttributeDescription( name="Energy", @@ -111,6 +122,7 @@ SENSORS = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_NEVER, ), ("emeter", "energyReturned"): BlockAttributeDescription( name="Energy Returned", @@ -118,6 +130,7 @@ SENSORS = { value=lambda value: round(value / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_NEVER, ), ("light", "energy"): BlockAttributeDescription( name="Energy", @@ -126,6 +139,7 @@ SENSORS = { device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, default_enabled=False, + last_reset=LAST_RESET_UPTIME, ), ("relay", "energy"): BlockAttributeDescription( name="Energy", @@ -133,6 +147,7 @@ SENSORS = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("roller", "rollerEnergy"): BlockAttributeDescription( name="Energy", @@ -140,6 +155,7 @@ SENSORS = { value=lambda value: round(value / 60 / 1000, 2), device_class=sensor.DEVICE_CLASS_ENERGY, state_class=sensor.STATE_CLASS_MEASUREMENT, + last_reset=LAST_RESET_UPTIME, ), ("sensor", "concentration"): BlockAttributeDescription( name="Gas Concentration", @@ -153,7 +169,7 @@ SENSORS = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_TEMPERATURE, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: block.extTemp != 999, + available=lambda block: cast(bool, block.extTemp != 999), ), ("sensor", "humidity"): BlockAttributeDescription( name="Humidity", @@ -161,7 +177,7 @@ SENSORS = { value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_HUMIDITY, state_class=sensor.STATE_CLASS_MEASUREMENT, - available=lambda block: block.extTemp != 999, + available=lambda block: cast(bool, block.extTemp != 999), ), ("sensor", "luminosity"): BlockAttributeDescription( name="Luminosity", @@ -186,7 +202,7 @@ SENSORS = { ), ("adc", "adc"): BlockAttributeDescription( name="ADC", - unit=VOLT, + unit=ELECTRIC_POTENTIAL_VOLT, value=lambda value: round(value, 1), device_class=sensor.DEVICE_CLASS_VOLTAGE, state_class=sensor.STATE_CLASS_MEASUREMENT, @@ -199,7 +215,7 @@ SENSORS = { ), } -REST_SENSORS = { +REST_SENSORS: Final = { "rssi": RestAttributeDescription( name="RSSI", unit=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -217,7 +233,11 @@ REST_SENSORS = { } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up sensors for device.""" if config_entry.data["sleep_period"]: await async_setup_entry_attribute_entities( @@ -236,36 +256,50 @@ class ShellySensor(ShellyBlockAttributeEntity, SensorEntity): """Represent a shelly sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" return self.attribute_value @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def last_reset(self) -> datetime | None: + """State class of sensor.""" + if self.description.last_reset == LAST_RESET_UPTIME: + self._last_value = get_device_uptime( + self.wrapper.device.status, self._last_value + ) + return dt.parse_datetime(self._last_value) + + if self.description.last_reset == LAST_RESET_NEVER: + return dt.utc_from_timestamp(0) + + return None + + @property + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" - return self._unit + return cast(str, self._unit) class ShellyRestSensor(ShellyRestAttributeEntity, SensorEntity): """Represent a shelly REST sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" return self.attribute_value @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" return self.description.unit @@ -274,7 +308,7 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a shelly sleeping sensor.""" @property - def state(self): + def state(self) -> StateType: """Return value of sensor.""" if self.block is not None: return self.attribute_value @@ -282,11 +316,11 @@ class ShellySleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.last_state @property - def state_class(self): + def state_class(self) -> str | None: """State class of sensor.""" return self.description.state_class @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return unit of sensor.""" - return self._unit + return cast(str, self._unit) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 6f3dd0b0136..3e35ba878e4 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,8 +1,14 @@ """Switch for Shelly.""" +from __future__ import annotations + +from typing import Any, cast + from aioshelly import Block from homeassistant.components.switch import SwitchEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ShellyDeviceWrapper from .const import COAP, DATA_CONFIG_ENTRY, DOMAIN @@ -10,7 +16,11 @@ from .entity import ShellyBlockEntity from .utils import async_remove_shelly_entity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up switches for device.""" wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry.entry_id][COAP] @@ -50,28 +60,28 @@ class RelaySwitch(ShellyBlockEntity, SwitchEntity): def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None: """Initialize relay switch.""" super().__init__(wrapper, block) - self.control_result = None + self.control_result: dict[str, Any] | None = None @property def is_on(self) -> bool: """If switch is on.""" if self.control_result: - return self.control_result["ison"] + return cast(bool, self.control_result["ison"]) - return self.block.output + return bool(self.block.output) - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on relay.""" self.control_result = await self.set_state(turn="on") self.async_write_ha_state() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off relay.""" self.control_result = await self.set_state(turn="off") self.async_write_ha_state() @callback - def _update_callback(self): + def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" self.control_result = None super()._update_callback() diff --git a/homeassistant/components/shelly/translations/ar.json b/homeassistant/components/shelly/translations/ar.json new file mode 100644 index 00000000000..ddc96dfc9bf --- /dev/null +++ b/homeassistant/components/shelly/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm_discovery": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0625\u0639\u062f\u0627\u062f {model} \u0639\u0644\u0649 {host} \u061f \n\n \u064a\u062c\u0628 \u0625\u064a\u0642\u0627\u0638 \u0627\u0644\u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u062a\u064a \u062a\u0639\u0645\u0644 \u0628\u0627\u0644\u0628\u0637\u0627\u0631\u064a\u0629 \u0648\u0627\u0644\u0645\u062d\u0645\u064a\u0629 \u0628\u0643\u0644\u0645\u0629 \u0645\u0631\u0648\u0631 \u0642\u0628\u0644 \u0645\u062a\u0627\u0628\u0639\u0629 \u0627\u0644\u0625\u0639\u062f\u0627\u062f.\n \u0633\u062a\u062a\u0645 \u0625\u0636\u0627\u0641\u0629 \u0627\u0644\u0623\u062c\u0647\u0632\u0629 \u0627\u0644\u062a\u064a \u062a\u0639\u0645\u0644 \u0628\u0627\u0644\u0628\u0637\u0627\u0631\u064a\u0629 \u063a\u064a\u0631 \u0627\u0644\u0645\u062d\u0645\u064a\u0629 \u0628\u0643\u0644\u0645\u0629 \u0645\u0631\u0648\u0631 \u0639\u0646\u062f \u062a\u0646\u0634\u064a\u0637 \u0627\u0644\u062c\u0647\u0627\u0632 \u060c \u0648\u064a\u0645\u0643\u0646\u0643 \u0627\u0644\u0622\u0646 \u062a\u0646\u0628\u064a\u0647 \u0627\u0644\u062c\u0647\u0627\u0632 \u064a\u062f\u0648\u064a\u064b\u0627 \u0628\u0627\u0633\u062a\u062e\u062f\u0627\u0645 \u0632\u0631 \u0639\u0644\u064a\u0647 \u0623\u0648 \u0627\u0646\u062a\u0638\u0627\u0631 \u062a\u062d\u062f\u064a\u062b \u0627\u0644\u0628\u064a\u0627\u0646\u0627\u062a \u0627\u0644\u062a\u0627\u0644\u064a \u0645\u0646 \u0627\u0644\u062c\u0647\u0627\u0632." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/shelly/translations/de.json b/homeassistant/components/shelly/translations/de.json index 70053a86144..513ff66dff1 100644 --- a/homeassistant/components/shelly/translations/de.json +++ b/homeassistant/components/shelly/translations/de.json @@ -12,7 +12,7 @@ "flow_title": "Shelly: {name}", "step": { "confirm_discovery": { - "description": "M\u00f6chten Sie das {model} bei {host} einrichten?\n\nBatteriebetriebene Ger\u00e4te, die passwortgesch\u00fctzt sind, m\u00fcssen aufgeweckt werden, bevor Sie mit dem Einrichten fortfahren.\nBatteriebetriebene Ger\u00e4te, die nicht passwortgesch\u00fctzt sind, werden hinzugef\u00fcgt, wenn das Ger\u00e4t aufwacht. Sie k\u00f6nnen das Ger\u00e4t nun manuell \u00fcber eine Taste am Ger\u00e4t aufwecken oder auf das n\u00e4chste Datenupdate des Ger\u00e4ts warten." + "description": "M\u00f6chtest du das {model} bei {host} einrichten?\n\nBatteriebetriebene Ger\u00e4te, die passwortgesch\u00fctzt sind, m\u00fcssen aufgeweckt werden, bevor du mit dem Einrichten fortf\u00e4hrst.\nBatteriebetriebene Ger\u00e4te, die nicht passwortgesch\u00fctzt sind, werden hinzugef\u00fcgt, wenn das Ger\u00e4t aufwacht. Du kannst das Ger\u00e4t nun manuell \u00fcber eine Taste am Ger\u00e4t aufwecken oder auf das n\u00e4chste Datenupdate des Ger\u00e4ts warten." }, "credentials": { "data": { diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 37b34dfe9e8..d1e2947d5ac 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -3,19 +3,19 @@ from __future__ import annotations from datetime import datetime, timedelta import logging +from typing import Any, Final, cast import aioshelly from homeassistant.const import EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import singleton +from homeassistant.helpers.typing import EventType from homeassistant.util.dt import utcnow from .const import ( BASIC_INPUTS_EVENTS_TYPES, - COAP, CONF_COAP_PORT, - DATA_CONFIG_ENTRY, DEFAULT_COAP_PORT, DOMAIN, SHBTN_INPUTS_EVENTS_TYPES, @@ -24,10 +24,12 @@ from .const import ( UPTIME_DEVIATION, ) -_LOGGER = logging.getLogger(__name__) +_LOGGER: Final = logging.getLogger(__name__) -async def async_remove_shelly_entity(hass, domain, unique_id): +async def async_remove_shelly_entity( + hass: HomeAssistant, domain: str, unique_id: str +) -> None: """Remove a Shelly entity.""" entity_reg = await hass.helpers.entity_registry.async_get_registry() entity_id = entity_reg.async_get_entity_id(domain, DOMAIN, unique_id) @@ -36,7 +38,7 @@ async def async_remove_shelly_entity(hass, domain, unique_id): entity_reg.async_remove(entity_id) -def temperature_unit(block_info: dict) -> str: +def temperature_unit(block_info: dict[str, Any]) -> str: """Detect temperature unit.""" if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F": return TEMP_FAHRENHEIT @@ -45,7 +47,7 @@ def temperature_unit(block_info: dict) -> str: def get_device_name(device: aioshelly.Device) -> str: """Naming for device.""" - return device.settings["name"] or device.settings["device"]["hostname"] + return cast(str, device.settings["name"] or device.settings["device"]["hostname"]) def get_number_of_channels(device: aioshelly.Device, block: aioshelly.Block) -> int: @@ -96,7 +98,7 @@ def get_device_channel_name( ): return entity_name - channel_name = None + channel_name: str | None = None mode = block.type + "s" if mode in device.settings: channel_name = device.settings[mode][int(block.channel)].get("name") @@ -112,7 +114,7 @@ def get_device_channel_name( return f"{entity_name} channel {chr(int(block.channel)+base)}" -def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool: +def is_momentary_input(settings: dict[str, Any], block: aioshelly.Block) -> bool: """Return true if input button settings is set to a momentary type.""" # Shelly Button type is fixed to momentary and no btn_type if settings["device"]["type"] in SHBTN_MODELS: @@ -134,7 +136,7 @@ def is_momentary_input(settings: dict, block: aioshelly.Block) -> bool: return button_type in ["momentary", "momentary_on_release"] -def get_device_uptime(status: dict, last_uptime: str) -> str: +def get_device_uptime(status: dict[str, Any], last_uptime: str | None) -> str: """Return device uptime string, tolerate up to 5 seconds deviation.""" delta_uptime = utcnow() - timedelta(seconds=status["uptime"]) @@ -178,22 +180,8 @@ def get_input_triggers( return triggers -def get_device_wrapper(hass: HomeAssistant, device_id: str): - """Get a Shelly device wrapper for the given device id.""" - if not hass.data.get(DOMAIN): - return None - - for config_entry in hass.data[DOMAIN][DATA_CONFIG_ENTRY]: - wrapper = hass.data[DOMAIN][DATA_CONFIG_ENTRY][config_entry].get(COAP) - - if wrapper and wrapper.device_id == device_id: - return wrapper - - return None - - @singleton.singleton("shelly_coap") -async def get_coap_context(hass): +async def get_coap_context(hass: HomeAssistant) -> aioshelly.COAP: """Get CoAP context to be used in all Shelly devices.""" context = aioshelly.COAP() if DOMAIN in hass.data: @@ -204,7 +192,7 @@ async def get_coap_context(hass): await context.initialize(port) @callback - def shutdown_listener(ev): + def shutdown_listener(ev: EventType) -> None: context.close() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_listener) @@ -212,7 +200,7 @@ async def get_coap_context(hass): return context -def get_device_sleep_period(settings: dict) -> int: +def get_device_sleep_period(settings: dict[str, Any]) -> int: """Return the device sleep period in seconds or 0 for non sleeping devices.""" sleep_period = 0 diff --git a/homeassistant/components/shopping_list/translations/de.json b/homeassistant/components/shopping_list/translations/de.json index 68372e9f4ac..f76d537349f 100644 --- a/homeassistant/components/shopping_list/translations/de.json +++ b/homeassistant/components/shopping_list/translations/de.json @@ -5,7 +5,7 @@ }, "step": { "user": { - "description": "M\u00f6chten Sie die Einkaufsliste konfigurieren?", + "description": "M\u00f6chtest du die Einkaufsliste konfigurieren?", "title": "Einkaufsliste" } } diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index 65ebbf0d882..a894623db47 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, PRECISION_TENTHS, TEMP_CELSIUS, @@ -119,6 +120,8 @@ class SHTSensor(SensorEntity): class SHTSensorTemperature(SHTSensor): """Representation of a temperature sensor.""" + _attr_device_class = DEVICE_CLASS_TEMPERATURE + @property def unit_of_measurement(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index a9b49765c19..c43faf5475c 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -19,7 +19,6 @@ from homeassistant import config_entries from homeassistant.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_ACCOUNT, @@ -62,7 +61,7 @@ ACCOUNT_SCHEMA = vol.Schema( DEFAULT_OPTIONS = {CONF_IGNORE_TIMESTAMPS: False, CONF_ZONES: None} -def validate_input(data: ConfigType) -> dict[str, str] | None: +def validate_input(data: dict[str, Any]) -> dict[str, str] | None: """Validate the input by the user.""" try: SIAAccount.validate_account(data[CONF_ACCOUNT], data.get(CONF_ENCRYPTION_KEY)) @@ -82,7 +81,7 @@ def validate_input(data: ConfigType) -> dict[str, str] | None: return validate_zones(data) -def validate_zones(data: ConfigType) -> dict[str, str] | None: +def validate_zones(data: dict[str, Any]) -> dict[str, str] | None: """Validate the zones field.""" if data[CONF_ZONES] == 0: return {"base": "invalid_zones"} @@ -102,10 +101,10 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize the config flow.""" - self._data: ConfigType = {} + self._data: dict[str, Any] = {} self._options: Mapping[str, Any] = {CONF_ACCOUNTS: {}} - async def async_step_user(self, user_input: ConfigType = None) -> FlowResult: + async def async_step_user(self, user_input: dict[str, Any] = None) -> FlowResult: """Handle the initial user step.""" errors: dict[str, str] | None = None if user_input is not None: @@ -116,7 +115,9 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_handle_data_and_route(user_input) - async def async_step_add_account(self, user_input: ConfigType = None) -> FlowResult: + async def async_step_add_account( + self, user_input: dict[str, Any] = None + ) -> FlowResult: """Handle the additional accounts steps.""" errors: dict[str, str] | None = None if user_input is not None: @@ -127,7 +128,9 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_handle_data_and_route(user_input) - async def async_handle_data_and_route(self, user_input: ConfigType) -> FlowResult: + async def async_handle_data_and_route( + self, user_input: dict[str, Any] + ) -> FlowResult: """Handle the user_input, check if configured and route to the right next step or create entry.""" self._update_data(user_input) @@ -141,7 +144,7 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): options=self._options, ) - def _update_data(self, user_input: ConfigType) -> None: + def _update_data(self, user_input: dict[str, Any]) -> None: """Parse the user_input and store in data and options attributes. If there is a port in the input or no data, assume it is fully new and overwrite. @@ -175,7 +178,7 @@ class SIAOptionsFlowHandler(config_entries.OptionsFlow): self.hub: SIAHub | None = None self.accounts_todo: list = [] - async def async_step_init(self, user_input: ConfigType = None) -> FlowResult: + async def async_step_init(self, user_input: dict[str, Any] = None) -> FlowResult: """Manage the SIA options.""" self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] assert self.hub is not None @@ -183,7 +186,7 @@ class SIAOptionsFlowHandler(config_entries.OptionsFlow): self.accounts_todo = [a.account_id for a in self.hub.sia_accounts] return await self.async_step_options() - async def async_step_options(self, user_input: ConfigType = None) -> FlowResult: + async def async_step_options(self, user_input: dict[str, Any] = None) -> FlowResult: """Create the options step for a account.""" errors: dict[str, str] | None = None if user_input is not None: diff --git a/homeassistant/components/sia/sia_entity_base.py b/homeassistant/components/sia/sia_entity_base.py index 5169702e67b..0a84615d6eb 100644 --- a/homeassistant/components/sia/sia_entity_base.py +++ b/homeassistant/components/sia/sia_entity_base.py @@ -43,7 +43,7 @@ class SIABaseEntity(RestoreEntity): self._cancel_availability_cb: CALLBACK_TYPE | None = None - self._attr_extra_state_attributes: dict[str, Any] = {} + self._attr_extra_state_attributes = {} self._attr_should_poll = False self._attr_name = SIA_NAME_FORMAT.format( self._port, self._account, self._zone, self._attr_device_class diff --git a/homeassistant/components/sia/strings.json b/homeassistant/components/sia/strings.json index f837d41056a..fe648c24e75 100644 --- a/homeassistant/components/sia/strings.json +++ b/homeassistant/components/sia/strings.json @@ -1,5 +1,4 @@ { - "title": "SIA Alarm Systems", "config": { "step": { "user": { diff --git a/homeassistant/components/sia/translations/ar.json b/homeassistant/components/sia/translations/ar.json new file mode 100644 index 00000000000..ebf7325b114 --- /dev/null +++ b/homeassistant/components/sia/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "\u0625\u0646\u0634\u0627\u0621 \u0627\u062a\u0635\u0627\u0644 \u0644\u0623\u0646\u0638\u0645\u0629 \u0627\u0644\u0625\u0646\u0630\u0627\u0631 \u0627\u0644\u0642\u0627\u0626\u0645\u0629 \u0639\u0644\u0649 SIA." + } + } + }, + "title": "\u0623\u0646\u0638\u0645\u0629 \u0625\u0646\u0630\u0627\u0631 SIA" +} \ No newline at end of file diff --git a/homeassistant/components/sia/translations/de.json b/homeassistant/components/sia/translations/de.json index 6da5a2c4750..d2c9fc05040 100644 --- a/homeassistant/components/sia/translations/de.json +++ b/homeassistant/components/sia/translations/de.json @@ -1,9 +1,9 @@ { "config": { "error": { - "invalid_account_format": "Das Konto ist kein Hex-Wert. Bitte verwenden Sie nur 0-9 und A-F.", + "invalid_account_format": "Das Konto ist kein Hex-Wert. Bitte verwende nur 0-9 und A-F.", "invalid_account_length": "Das Konto hat nicht die richtige L\u00e4nge. Es muss zwischen 3 und 16 Zeichen lang sein.", - "invalid_key_format": "Der Schl\u00fcssel ist kein Hex-Wert, bitte verwenden Sie nur 0-9 und A-F.", + "invalid_key_format": "Der Schl\u00fcssel ist kein Hex-Wert, bitte verwende nur 0-9 und A-F.", "invalid_key_length": "Der Schl\u00fcssel hat nicht die richtige L\u00e4nge. Er muss 16, 24 oder 32 Hex-Zeichen lang sein.", "invalid_ping": "Das Ping-Intervall muss zwischen 1 und 1440 Minuten liegen.", "invalid_zones": "Es muss mindestens eine Zone vorhanden sein.", @@ -41,7 +41,7 @@ "ignore_timestamps": "Ignorieren der Zeitstempelpr\u00fcfung der SIA-Ereignisse", "zones": "Anzahl an Zonen f\u00fcr das Konto" }, - "description": "Stellen Sie die Optionen f\u00fcr das Konto {account} ein:", + "description": "Stelle die Optionen f\u00fcr das Konto {account} ein:", "title": "Optionen f\u00fcr das SIA-Setup." } } diff --git a/homeassistant/components/sia/translations/fr.json b/homeassistant/components/sia/translations/fr.json new file mode 100644 index 00000000000..2b3188dd082 --- /dev/null +++ b/homeassistant/components/sia/translations/fr.json @@ -0,0 +1,50 @@ +{ + "config": { + "error": { + "invalid_account_format": "Le compte n'est pas une valeur hexad\u00e9cimale, veuillez utiliser uniquement 0-9 et AF.", + "invalid_account_length": "Le compte n'est pas de la bonne longueur, il doit faire entre 3 et 16 caract\u00e8res.", + "invalid_key_format": "La cl\u00e9 n'est pas une valeur hexad\u00e9cimale, veuillez utiliser uniquement 0-9 et AF.", + "invalid_key_length": "La cl\u00e9 n'est pas de la bonne longueur, elle doit comporter 16, 24 ou 32 caract\u00e8res hexad\u00e9cimaux.", + "invalid_ping": "L'intervalle de ping doit \u00eatre compris entre 1 et 1440 minutes.", + "invalid_zones": "Il doit y avoir au moins 1 zone.", + "unknown": "Erreur inattendue" + }, + "step": { + "additional_account": { + "data": { + "account": "Identifiant du compte", + "additional_account": "Comptes suppl\u00e9mentaires", + "encryption_key": "Cl\u00e9 de cryptage", + "ping_interval": "Intervalle de ping (min)", + "zones": "Nombre de zones pour le compte" + }, + "title": "Ajouter un autre compte au port actuel." + }, + "user": { + "data": { + "account": "Identifiant de compte", + "additional_account": "Comptes suppl\u00e9mentaires", + "encryption_key": "Cl\u00e9 de cryptage", + "ping_interval": "Intervalle de ping (min)", + "port": "Port", + "protocol": "Protocole", + "zones": "Nombre de zones pour le compte" + }, + "title": "Cr\u00e9er une connexion pour les syst\u00e8mes d'alarme bas\u00e9s sur SIA." + } + } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Ignorer la v\u00e9rification de l'horodatage des \u00e9v\u00e9nements SIA", + "zones": "Nombre de zones pour le compte" + }, + "description": "D\u00e9finisser les options du compte\u00a0: {account}", + "title": "Options pour la configuration SIA." + } + } + }, + "title": "Syst\u00e8mes d'alarme SIA" +} \ No newline at end of file diff --git a/homeassistant/components/sia/translations/hu.json b/homeassistant/components/sia/translations/hu.json index f5538bfd6b5..6a5c609e1f6 100644 --- a/homeassistant/components/sia/translations/hu.json +++ b/homeassistant/components/sia/translations/hu.json @@ -1,15 +1,50 @@ { "config": { "error": { + "invalid_account_format": "A sz\u00e1mla nem hexa\u00e9rt\u00e9k, k\u00e9rj\u00fck, csak a 0-9 \u00e9s az A-F \u00e9rt\u00e9keket haszn\u00e1ljon.", + "invalid_account_length": "A fi\u00f3k nem megfelel\u0151 hossz\u00fas\u00e1g\u00fa, 3 \u00e9s 16 karakter k\u00f6z\u00f6tt kell lennie.", + "invalid_key_format": "A kulcs nem hexa\u00e9rt\u00e9k, k\u00e9rj\u00fck, csak a 0-9 \u00e9s az A-F \u00e9rt\u00e9keket haszn\u00e1ljon.", + "invalid_key_length": "A kulcs nem megfelel\u0151 hossz\u00fas\u00e1g\u00fa, 16, 24 vagy 32 hexa karakterb\u0151l kell \u00e1llnia.", + "invalid_ping": "A ping intervallumnak 1 \u00e9s 1440 perc k\u00f6z\u00f6tt kell lennie.", + "invalid_zones": "Legal\u00e1bb 1 z\u00f3n\u00e1nak kell lennie.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { + "additional_account": { + "data": { + "account": "Fi\u00f3k ID", + "additional_account": "Tov\u00e1bbi fi\u00f3kok", + "encryption_key": "Titkos\u00edt\u00e1si kulcs", + "ping_interval": "Ping-intervallum (perc)", + "zones": "A fi\u00f3k z\u00f3n\u00e1inak sz\u00e1ma" + }, + "title": "Adjon hozz\u00e1 egy m\u00e1sik fi\u00f3kot az aktu\u00e1lis porthoz." + }, "user": { "data": { + "account": "Fi\u00f3k ID", + "additional_account": "Tov\u00e1bbi fi\u00f3kok", + "encryption_key": "Titkos\u00edt\u00e1si kulcs", + "ping_interval": "Ping-intervallum (perc)", "port": "Port", - "protocol": "Protokoll" - } + "protocol": "Protokoll", + "zones": "A fi\u00f3k z\u00f3n\u00e1inak sz\u00e1ma" + }, + "title": "Hozzon l\u00e9tre kapcsolatot SIA alap\u00fa riaszt\u00f3rendszerekhez." } } - } + }, + "options": { + "step": { + "options": { + "data": { + "ignore_timestamps": "Hagyja figyelmen k\u00edv\u00fcl a SIA esem\u00e9nyek id\u0151b\u00e9lyeg-ellen\u0151rz\u00e9s\u00e9t", + "zones": "A fi\u00f3k z\u00f3n\u00e1inak sz\u00e1ma" + }, + "description": "\u00c1ll\u00edtsa be a fi\u00f3k be\u00e1ll\u00edt\u00e1sait: {account}", + "title": "A SIA be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek." + } + } + }, + "title": "SIA riaszt\u00f3rendszerek" } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d4e43631b78..0853aa3974c 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -1,17 +1,28 @@ """Support for SimpliSafe alarm systems.""" +from __future__ import annotations + import asyncio +from collections.abc import Awaitable +from typing import Callable, cast from uuid import UUID from simplipy import get_api +from simplipy.api import API from simplipy.errors import ( EndpointUnavailableError, InvalidCredentialsError, SimplipyError, ) +from simplipy.sensor.v2 import SensorV2 +from simplipy.sensor.v3 import SensorV3 +from simplipy.system import SystemNotification +from simplipy.system.v2 import SystemV2 +from simplipy.system.v3 import SystemV3 import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -45,8 +56,6 @@ from .const import ( VOLUMES, ) -DATA_LISTENER = "listener" - EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" DEFAULT_SOCKET_MIN_RETRY = 15 @@ -111,7 +120,7 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend( CONFIG_SCHEMA = cv.deprecated(DOMAIN) -async def async_get_client_id(hass): +async def async_get_client_id(hass: HomeAssistant) -> str: """Get a client ID (based on the HASS unique ID) for the SimpliSafe API. Note that SimpliSafe requires full, "dashed" versions of UUIDs. @@ -120,7 +129,9 @@ async def async_get_client_id(hass): return str(UUID(hass_id)) -async def async_register_base_station(hass, system, config_entry_id): +async def async_register_base_station( + hass: HomeAssistant, system: SystemV2 | SystemV3, config_entry_id: str +) -> None: """Register a new bridge.""" device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( @@ -132,12 +143,11 @@ async def async_register_base_station(hass, system, config_entry_id): ) -async def async_setup_entry(hass, config_entry): # noqa: C901 - """Set up SimpliSafe as config entry.""" - hass.data.setdefault(DOMAIN, {DATA_CLIENT: {}, DATA_LISTENER: {}}) - hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = [] - hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = [] - +@callback +def _async_standardize_config_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Bring a config entry up to current standards.""" if CONF_PASSWORD not in config_entry.data: raise ConfigEntryAuthFailed("Config schema change requires re-authentication") @@ -157,6 +167,14 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 if entry_updates: hass.config_entries.async_update_entry(config_entry, **entry_updates) + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up SimpliSafe as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_CLIENT: {}}) + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = [] + + _async_standardize_config_entry(hass, config_entry) + _verify_domain_control = verify_domain_control(hass, DOMAIN) client_id = await async_get_client_id(hass) @@ -186,10 +204,12 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @callback - def verify_system_exists(coro): + def verify_system_exists( + coro: Callable[..., Awaitable] + ) -> Callable[..., Awaitable]: """Log an error if a service call uses an invalid system ID.""" - async def decorator(call): + async def decorator(call: ServiceCall) -> None: """Decorate.""" system_id = int(call.data[ATTR_SYSTEM_ID]) if system_id not in simplisafe.systems: @@ -200,10 +220,10 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 return decorator @callback - def v3_only(coro): + def v3_only(coro: Callable[..., Awaitable]) -> Callable[..., Awaitable]: """Log an error if the decorated coroutine is called with a v2 system.""" - async def decorator(call): + async def decorator(call: ServiceCall) -> None: """Decorate.""" system = simplisafe.systems[int(call.data[ATTR_SYSTEM_ID])] if system.version != 3: @@ -215,43 +235,40 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 @verify_system_exists @_verify_domain_control - async def clear_notifications(call): + async def clear_notifications(call: ServiceCall) -> None: """Clear all active notifications.""" system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.clear_notifications() except SimplipyError as err: LOGGER.error("Error during service call: %s", err) - return @verify_system_exists @_verify_domain_control - async def remove_pin(call): + async def remove_pin(call: ServiceCall) -> None: """Remove a PIN.""" system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.remove_pin(call.data[ATTR_PIN_LABEL_OR_VALUE]) except SimplipyError as err: LOGGER.error("Error during service call: %s", err) - return @verify_system_exists @_verify_domain_control - async def set_pin(call): + async def set_pin(call: ServiceCall) -> None: """Set a PIN.""" system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] try: await system.set_pin(call.data[ATTR_PIN_LABEL], call.data[ATTR_PIN_VALUE]) except SimplipyError as err: LOGGER.error("Error during service call: %s", err) - return @verify_system_exists @v3_only @_verify_domain_control - async def set_system_properties(call): + async def set_system_properties(call: ServiceCall) -> None: """Set one or more system parameters.""" - system = simplisafe.systems[call.data[ATTR_SYSTEM_ID]] + system = cast(SystemV3, simplisafe.systems[call.data[ATTR_SYSTEM_ID]]) try: await system.set_properties( { @@ -262,9 +279,8 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 ) except SimplipyError as err: LOGGER.error("Error during service call: %s", err) - return - for service, method, schema in [ + for service, method, schema in ( ("clear_notifications", clear_notifications, None), ("remove_pin", remove_pin, SERVICE_REMOVE_PIN_SCHEMA), ("set_pin", set_pin, SERVICE_SET_PIN_SCHEMA), @@ -273,28 +289,24 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 set_system_properties, SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA, ), - ]: + ): async_register_admin_service(hass, DOMAIN, service, method, schema=schema) - hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id].append( - config_entry.add_update_listener(async_reload_entry) - ) + config_entry.async_on_unload(config_entry.add_update_listener(async_reload_entry)) return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a SimpliSafe config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data[DOMAIN][DATA_CLIENT].pop(entry.entry_id) - for remove_listener in hass.data[DOMAIN][DATA_LISTENER].pop(entry.entry_id): - remove_listener() return unload_ok -async def async_reload_entry(hass, config_entry): +async def async_reload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Handle an options update.""" await hass.config_entries.async_reload(config_entry.entry_id) @@ -302,17 +314,19 @@ async def async_reload_entry(hass, config_entry): class SimpliSafe: """Define a SimpliSafe data object.""" - def __init__(self, hass, config_entry, api): + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, api: API + ) -> None: """Initialize.""" self._api = api self._hass = hass - self._system_notifications = {} + self._system_notifications: dict[int, set[SystemNotification]] = {} self.config_entry = config_entry - self.coordinator = None - self.systems = {} + self.coordinator: DataUpdateCoordinator | None = None + self.systems: dict[int, SystemV2 | SystemV3] = {} @callback - def _async_process_new_notifications(self, system): + def _async_process_new_notifications(self, system: SystemV2 | SystemV3) -> None: """Act on any new system notifications.""" if self._hass.state != CoreState.running: # If HASS isn't fully running yet, it may cause the SIMPLISAFE_NOTIFICATION @@ -331,8 +345,6 @@ class SimpliSafe: LOGGER.debug("New system notifications: %s", to_add) - self._system_notifications[system.system_id].update(to_add) - for notification in to_add: text = notification.text if notification.link: @@ -348,7 +360,9 @@ class SimpliSafe: }, ) - async def async_init(self): + self._system_notifications[system.system_id] = latest_notifications + + async def async_init(self) -> None: """Initialize the data class.""" self.systems = await self._api.get_systems() for system in self.systems.values(): @@ -368,10 +382,10 @@ class SimpliSafe: update_method=self.async_update, ) - async def async_update(self): + async def async_update(self) -> None: """Get updated data from SimpliSafe.""" - async def async_update_system(system): + async def async_update_system(system: SystemV2 | SystemV3) -> None: """Update a system.""" await system.update(cached=system.version != 3) self._async_process_new_notifications(system) @@ -396,72 +410,65 @@ class SimpliSafe: class SimpliSafeEntity(CoordinatorEntity): """Define a base SimpliSafe entity.""" - def __init__(self, simplisafe, system, name, *, serial=None): + def __init__( + self, + simplisafe: SimpliSafe, + system: SystemV2 | SystemV3, + name: str, + *, + serial: str | None = None, + ) -> None: """Initialize.""" + assert simplisafe.coordinator super().__init__(simplisafe.coordinator) - self._name = name - self._online = True - self._simplisafe = simplisafe - self._system = system if serial: self._serial = serial else: self._serial = system.serial - self._attrs = {ATTR_SYSTEM_ID: system.system_id} - - self._device_info = { + self._attr_extra_state_attributes = {ATTR_SYSTEM_ID: system.system_id} + self._attr_device_info = { "identifiers": {(DOMAIN, system.system_id)}, "manufacturer": "SimpliSafe", "model": system.version, "name": name, "via_device": (DOMAIN, system.serial), } + self._attr_name = f"{system.address} {name}" + self._attr_unique_id = self._serial + self._online = True + self._simplisafe = simplisafe + self._system = system @property - def available(self): + def available(self) -> bool: """Return whether the entity is available.""" # We can easily detect if the V3 system is offline, but no simple check exists # for the V2 system. Therefore, assuming the coordinator hasn't failed, we mark # the entity as available if: # 1. We can verify that the system is online (assuming True if we can't) # 2. We can verify that the entity is online - return not (self._system.version == 3 and self._system.offline) and self._online + if isinstance(self._system, SystemV3): + system_offline = self._system.offline + else: + system_offline = False - @property - def device_info(self): - """Return device registry information for this entity.""" - return self._device_info - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attrs - - @property - def name(self): - """Return the name of the entity.""" - return f"{self._system.address} {self._name}" - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return self._serial + return super().available and self._online and not system_offline @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Update the entity with new REST API data.""" self.async_update_from_rest_api() self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() self.async_update_from_rest_api() @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" raise NotImplementedError() @@ -469,18 +476,24 @@ class SimpliSafeEntity(CoordinatorEntity): class SimpliSafeBaseSensor(SimpliSafeEntity): """Define a SimpliSafe base (binary) sensor.""" - def __init__(self, simplisafe, system, sensor): + def __init__( + self, + simplisafe: SimpliSafe, + system: SystemV2 | SystemV3, + sensor: SensorV2 | SensorV3, + ) -> None: """Initialize.""" super().__init__(simplisafe, system, sensor.name, serial=sensor.serial) - self._device_info["identifiers"] = {(DOMAIN, sensor.serial)} - self._device_info["model"] = sensor.type.name - self._device_info["name"] = sensor.name - self._sensor = sensor - self._sensor_type_human_name = " ".join( - [w.title() for w in self._sensor.type.name.split("_")] - ) - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._system.address} {self._name} {self._sensor_type_human_name}" + self._attr_device_info = { + "identifiers": {(DOMAIN, sensor.serial)}, + "manufacturer": "SimpliSafe", + "model": sensor.type.name, + "name": sensor.name, + "via_device": (DOMAIN, system.serial), + } + + human_friendly_name = " ".join([w.title() for w in sensor.type.name.split("_")]) + self._attr_name = f"{super().name} {human_friendly_name}" + + self._sensor = sensor diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index 1f224683a41..8520cd2b50f 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -1,8 +1,10 @@ """Support for SimpliSafe alarm control panels.""" -import re +from __future__ import annotations from simplipy.errors import SimplipyError from simplipy.system import SystemStates +from simplipy.system.v2 import SystemV2 +from simplipy.system.v3 import SystemV3 from homeassistant.components.alarm_control_panel import ( FORMAT_NUMBER, @@ -13,6 +15,7 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CODE, STATE_ALARM_ARMED_AWAY, @@ -21,9 +24,10 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafeEntity +from . import SimpliSafe, SimpliSafeEntity from .const import ( ATTR_ALARM_DURATION, ATTR_ALARM_VOLUME, @@ -48,7 +52,9 @@ ATTR_WALL_POWER_LEVEL = "wall_power_level" ATTR_WIFI_STRENGTH = "wifi_strength" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up a SimpliSafe alarm control panel based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] async_add_entities( @@ -60,57 +66,37 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): """Representation of a SimpliSafe alarm.""" - def __init__(self, simplisafe, system): + def __init__(self, simplisafe: SimpliSafe, system: SystemV2 | SystemV3) -> None: """Initialize the SimpliSafe alarm.""" super().__init__(simplisafe, system, "Alarm Control Panel") - self._changed_by = None + + if code := self._simplisafe.config_entry.options.get(CONF_CODE): + if code.isdigit(): + self._attr_code_format = FORMAT_NUMBER + else: + self._attr_code_format = FORMAT_TEXT + self._attr_supported_features = SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY self._last_event = None if system.alarm_going_off: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED elif system.state == SystemStates.away: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif system.state in ( SystemStates.away_count, SystemStates.exit_delay, SystemStates.home_count, ): - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif system.state == SystemStates.home: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME elif system.state == SystemStates.off: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED else: - self._state = None - - @property - def changed_by(self): - """Return info about who changed the alarm last.""" - return self._changed_by - - @property - def code_format(self): - """Return one or more digits/characters.""" - if not self._simplisafe.config_entry.options.get(CONF_CODE): - return None - if isinstance( - self._simplisafe.config_entry.options[CONF_CODE], str - ) and re.search("^\\d+$", self._simplisafe.config_entry.options[CONF_CODE]): - return FORMAT_NUMBER - return FORMAT_TEXT - - @property - def state(self): - """Return the state of the entity.""" - return self._state - - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY + self._attr_state = None @callback - def _is_code_valid(self, code, state): + def _is_code_valid(self, code: str | None, state: str) -> bool: """Validate that a code matches the required one.""" if not self._simplisafe.config_entry.options.get(CONF_CODE): return True @@ -123,7 +109,7 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): return True - async def async_alarm_disarm(self, code=None): + 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 @@ -134,10 +120,10 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): LOGGER.error('Error while disarming "%s": %s', self._system.system_id, err) return - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED self.async_write_ha_state() - async def async_alarm_arm_home(self, code=None): + 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 @@ -150,10 +136,10 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): ) return - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME self.async_write_ha_state() - async def async_alarm_arm_away(self, code=None): + 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 @@ -166,14 +152,14 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): ) return - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING self.async_write_ha_state() @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - if self._system.version == 3: - self._attrs.update( + if isinstance(self._system, SystemV3): + self._attr_extra_state_attributes.update( { ATTR_ALARM_DURATION: self._system.alarm_duration, ATTR_ALARM_VOLUME: VOLUME_STRING_MAP[self._system.alarm_volume], @@ -194,18 +180,15 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanelEntity): } ) - # Although system state updates are designed the come via the websocket, the - # SimpliSafe cloud can sporadically fail to send those updates as expected; so, - # just in case, we synchronize the state via the REST API, too: if self._system.state == SystemStates.alarm: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED elif self._system.state == SystemStates.away: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif self._system.state in (SystemStates.away_count, SystemStates.exit_delay): - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif self._system.state == SystemStates.home: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME elif self._system.state == SystemStates.off: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED else: - self._state = None + self._attr_state = None diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index a92309c1123..1c471d10ce8 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -1,5 +1,9 @@ """Support for SimpliSafe binary sensors.""" -from simplipy.entity import EntityTypes +from __future__ import annotations + +from simplipy.entity import Entity as SimplipyEntity, EntityTypes +from simplipy.system.v2 import SystemV2 +from simplipy.system.v3 import SystemV3 from homeassistant.components.binary_sensor import ( DEVICE_CLASS_BATTERY, @@ -11,9 +15,11 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_SMOKE, BinarySensorEntity, ) -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafeBaseSensor +from . import SimpliSafe, SimpliSafeBaseSensor from .const import DATA_CLIENT, DOMAIN, LOGGER SUPPORTED_BATTERY_SENSOR_TYPES = [ @@ -39,10 +45,13 @@ TRIGGERED_SENSOR_TYPES = { } -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up SimpliSafe binary sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] - sensors = [] + + sensors: list[BatteryBinarySensor | TriggeredBinarySensor] = [] for system in simplisafe.systems.values(): if system.version == 2: @@ -68,52 +77,41 @@ async def async_setup_entry(hass, entry, async_add_entities): class TriggeredBinarySensor(SimpliSafeBaseSensor, BinarySensorEntity): """Define a binary sensor related to whether an entity has been triggered.""" - def __init__(self, simplisafe, system, sensor, device_class): + def __init__( + self, + simplisafe: SimpliSafe, + system: SystemV2 | SystemV3, + sensor: SimplipyEntity, + device_class: str, + ) -> None: """Initialize.""" super().__init__(simplisafe, system, sensor) - self._device_class = device_class - self._is_on = False - @property - def device_class(self): - """Return type of sensor.""" - return self._device_class - - @property - def is_on(self): - """Return true if the sensor is on.""" - return self._is_on + self._attr_device_class = device_class @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._is_on = self._sensor.triggered + self._attr_is_on = self._sensor.triggered class BatteryBinarySensor(SimpliSafeBaseSensor, BinarySensorEntity): """Define a SimpliSafe battery binary sensor entity.""" - def __init__(self, simplisafe, system, sensor): + _attr_device_class = DEVICE_CLASS_BATTERY + + def __init__( + self, + simplisafe: SimpliSafe, + system: SystemV2 | SystemV3, + sensor: SimplipyEntity, + ) -> None: """Initialize.""" super().__init__(simplisafe, system, sensor) - self._is_low = False - @property - def device_class(self): - """Return type of sensor.""" - return DEVICE_CLASS_BATTERY - - @property - def unique_id(self): - """Return unique ID of sensor.""" - return f"{self._sensor.serial}-battery" - - @property - def is_on(self): - """Return true if the battery is low.""" - return self._is_low + self._attr_unique_id = f"{super().unique_id}-battery" @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._is_low = self._sensor.low_battery + self._attr_is_on = self._sensor.low_battery diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index ac31779175f..31ae125046c 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,5 +1,10 @@ """Config flow to configure the SimpliSafe component.""" +from __future__ import annotations + +from typing import Any + from simplipy import get_api +from simplipy.api import API from simplipy.errors import ( InvalidCredentialsError, PendingAuthorizationError, @@ -8,9 +13,12 @@ from simplipy.errors import ( import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import ConfigType from . import async_get_client_id from .const import DOMAIN, LOGGER @@ -30,20 +38,25 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._code = None - self._password = None - self._username = None + self._code: str | None = None + self._password: str | None = None + self._username: str | None = None @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> SimpliSafeOptionsFlowHandler: """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) - async def _async_get_simplisafe_api(self): + async def _async_get_simplisafe_api(self) -> API: """Get an authenticated SimpliSafe API client.""" + assert self._username + assert self._password + client_id = await async_get_client_id(self.hass) websession = aiohttp_client.async_get_clientsession(self.hass) @@ -54,7 +67,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): session=websession, ) - async def _async_login_during_step(self, *, step_id, form_schema): + async def _async_login_during_step( + self, *, step_id: str, form_schema: vol.Schema + ) -> FlowResult: """Attempt to log into the API from within a config flow step.""" errors = {} @@ -84,8 +99,10 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_finish(self, user_input=None): + async def async_step_finish(self, user_input: dict[str, Any]) -> FlowResult: """Handle finish config entry setup.""" + assert self._username + existing_entry = await self.async_set_unique_id(self._username) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) @@ -95,7 +112,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self._username, data=user_input) - async def async_step_mfa(self, user_input=None): + async def async_step_mfa( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle multi-factor auth confirmation.""" if user_input is None: return self.async_show_form(step_id="mfa") @@ -116,14 +135,16 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_reauth(self, config): + async def async_step_reauth(self, config: ConfigType) -> FlowResult: """Handle configuration by re-auth.""" self._code = config.get(CONF_CODE) self._username = config[CONF_USERNAME] return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle re-auth completion.""" if not user_input: return self.async_show_form( @@ -136,7 +157,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", form_schema=PASSWORD_DATA_SCHEMA ) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return self.async_show_form(step_id="user", data_schema=FULL_DATA_SCHEMA) @@ -156,11 +179,13 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow): """Handle a SimpliSafe options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 8bfda08c1a5..e912eedb955 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -1,11 +1,18 @@ """Support for SimpliSafe locks.""" +from __future__ import annotations + +from typing import Any + from simplipy.errors import SimplipyError -from simplipy.lock import LockStates +from simplipy.lock import Lock, LockStates +from simplipy.system.v3 import SystemV3 from homeassistant.components.lock import LockEntity -from homeassistant.core import callback +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SimpliSafeEntity +from . import SimpliSafe, SimpliSafeEntity from .const import DATA_CLIENT, DOMAIN, LOGGER ATTR_LOCK_LOW_BATTERY = "lock_low_battery" @@ -13,7 +20,9 @@ ATTR_JAMMED = "jammed" ATTR_PIN_PAD_LOW_BATTERY = "pin_pad_low_battery" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up SimpliSafe locks based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] locks = [] @@ -32,18 +41,13 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimpliSafeLock(SimpliSafeEntity, LockEntity): """Define a SimpliSafe lock.""" - def __init__(self, simplisafe, system, lock): + def __init__(self, simplisafe: SimpliSafe, system: SystemV3, lock: Lock) -> None: """Initialize.""" super().__init__(simplisafe, system, lock.name, serial=lock.serial) + self._lock = lock - self._is_locked = None - @property - def is_locked(self): - """Return true if the lock is locked.""" - return self._is_locked - - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: dict[str, Any]) -> None: """Lock the lock.""" try: await self._lock.lock() @@ -51,10 +55,10 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): LOGGER.error('Error while locking "%s": %s', self._lock.name, err) return - self._is_locked = True + self._attr_is_locked = True self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: dict[str, Any]) -> None: """Unlock the lock.""" try: await self._lock.unlock() @@ -62,13 +66,13 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): LOGGER.error('Error while unlocking "%s": %s', self._lock.name, err) return - self._is_locked = False + self._attr_is_locked = False self.async_write_ha_state() @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, ATTR_JAMMED: self._lock.state == LockStates.jammed, @@ -76,4 +80,4 @@ class SimpliSafeLock(SimpliSafeEntity, LockEntity): } ) - self._is_locked = self._lock.state == LockStates.locked + self._attr_is_locked = self._lock.state == LockStates.locked diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 3ec1e38ad4d..8c23e575cc3 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==11.0.2"], + "requirements": ["simplisafe-python==11.0.3"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index 9f93a6f9e87..149319cd5bd 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -2,14 +2,18 @@ from simplipy.entity import EntityTypes from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SimpliSafeBaseSensor from .const import DATA_CLIENT, DOMAIN, LOGGER -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up SimpliSafe freeze sensors based on a config entry.""" simplisafe = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] sensors = [] @@ -29,32 +33,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class SimplisafeFreezeSensor(SimpliSafeBaseSensor, SensorEntity): """Define a SimpliSafe freeze sensor entity.""" - def __init__(self, simplisafe, system, sensor): - """Initialize.""" - super().__init__(simplisafe, system, sensor) - self._state = None - - @property - def device_class(self): - """Return type of sensor.""" - return DEVICE_CLASS_TEMPERATURE - - @property - def unique_id(self): - """Return unique ID of sensor.""" - return self._sensor.serial - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def state(self): - """Return the sensor state.""" - return self._state + _attr_device_class = DEVICE_CLASS_TEMPERATURE + _attr_unit_of_measurement = TEMP_FAHRENHEIT @callback - def async_update_from_rest_api(self): + def async_update_from_rest_api(self) -> None: """Update the entity with the provided REST API data.""" - self._state = self._sensor.temperature + self._attr_state = self._sensor.temperature diff --git a/homeassistant/components/simplisafe/translations/ca.json b/homeassistant/components/simplisafe/translations/ca.json index e8bb80d1b88..9eb1390466c 100644 --- a/homeassistant/components/simplisafe/translations/ca.json +++ b/homeassistant/components/simplisafe/translations/ca.json @@ -19,7 +19,7 @@ "data": { "password": "Contrasenya" }, - "description": "L'acc\u00e9s ha caducat o ha estat revocat. Introdueix la teva contrasenya per tornar a vincular el compte.", + "description": "L'acc\u00e9s ha caducat o ha estat revocat. Introdueix la contrasenya per tornar a vincular el compte.", "title": "Reautenticaci\u00f3 de la integraci\u00f3" }, "user": { diff --git a/homeassistant/components/simplisafe/translations/de.json b/homeassistant/components/simplisafe/translations/de.json index 046c46c01ac..ae354b2138a 100644 --- a/homeassistant/components/simplisafe/translations/de.json +++ b/homeassistant/components/simplisafe/translations/de.json @@ -12,7 +12,7 @@ }, "step": { "mfa": { - "description": "Pr\u00fcfe deine E-Mail auf einen Link von SimpliSafe. Kehre nach der Verifizierung des Links hierher zur\u00fcck, um die Installation der Integration abzuschlie\u00dfen.", + "description": "Pr\u00fcfe deine E-Mail auf einen Link von SimpliSafe. Kehre nach der Verifizierung des Links hierher zur\u00fcck, um die Installation der Integration abzuschlie\u00dfen.", "title": "SimpliSafe Multi-Faktor-Authentifizierung" }, "reauth_confirm": { @@ -26,7 +26,7 @@ "data": { "code": "Code (wird in der Benutzeroberfl\u00e4che von Home Assistant verwendet)", "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Gib deine Informationen ein" } @@ -38,7 +38,7 @@ "data": { "code": "Code (wird in der Benutzeroberfl\u00e4che von Home Assistant verwendet)" }, - "title": "Konfigurieren Sie SimpliSafe" + "title": "Konfiguriere SimpliSafe" } } } diff --git a/homeassistant/components/simplisafe/translations/et.json b/homeassistant/components/simplisafe/translations/et.json index e815785f0b5..7eea7dc100d 100644 --- a/homeassistant/components/simplisafe/translations/et.json +++ b/homeassistant/components/simplisafe/translations/et.json @@ -19,7 +19,7 @@ "data": { "password": "Salas\u00f5na" }, - "description": "Juurdep\u00e4\u00e4suluba on aegunud v\u00f5i on see t\u00fchistatud. Konto uuesti linkimiseks sisesta oma salas\u00f5na.", + "description": "Juurdep\u00e4\u00e4suluba on aegunud v\u00f5i on see t\u00fchistatud. Konto taassidumiseks sisesta salas\u00f5na.", "title": "Taastuvasta SimpliSafe'i konto" }, "user": { diff --git a/homeassistant/components/simplisafe/translations/he.json b/homeassistant/components/simplisafe/translations/he.json index dd3969f269f..6dcf2c0c07b 100644 --- a/homeassistant/components/simplisafe/translations/he.json +++ b/homeassistant/components/simplisafe/translations/he.json @@ -12,7 +12,8 @@ "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4" }, - "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da." + "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, "user": { "data": { diff --git a/homeassistant/components/simplisafe/translations/it.json b/homeassistant/components/simplisafe/translations/it.json index 13e7fe4562a..37671d75917 100644 --- a/homeassistant/components/simplisafe/translations/it.json +++ b/homeassistant/components/simplisafe/translations/it.json @@ -19,7 +19,7 @@ "data": { "password": "Password" }, - "description": "L'accesso \u00e8 scaduto o revocato. Inserisci la password per ri-collegare il tuo account.", + "description": "Il tuo accesso \u00e8 scaduto o revocato. Inserisci la password per ricollegare il tuo account.", "title": "Autenticare nuovamente l'integrazione" }, "user": { diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py new file mode 100644 index 00000000000..f301100fa6c --- /dev/null +++ b/homeassistant/components/siren/__init__.py @@ -0,0 +1,154 @@ +"""Component to interface with various sirens/chimes.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any, TypedDict, cast, final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_AVAILABLE_TONES, + ATTR_DURATION, + ATTR_TONE, + ATTR_VOLUME_LEVEL, + DOMAIN, + SUPPORT_DURATION, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + +TURN_ON_SCHEMA = { + vol.Optional(ATTR_TONE): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_DURATION): cv.positive_int, + vol.Optional(ATTR_VOLUME_LEVEL): cv.small_float, +} + + +class SirenTurnOnServiceParameters(TypedDict, total=False): + """Represent possible parameters to siren.turn_on service data dict type.""" + + tone: int | str + duration: int + volume_level: float + + +def process_turn_on_params( + siren: SirenEntity, params: SirenTurnOnServiceParameters +) -> SirenTurnOnServiceParameters: + """ + Process turn_on service params. + + Filters out unsupported params and validates the rest. + """ + supported_features = siren.supported_features or 0 + + if not supported_features & SUPPORT_TONES: + params.pop(ATTR_TONE, None) + elif (tone := params.get(ATTR_TONE)) is not None and ( + not siren.available_tones or tone not in siren.available_tones + ): + raise ValueError(f"Invalid tone received for entity {siren.entity_id}: {tone}") + + if not supported_features & SUPPORT_DURATION: + params.pop(ATTR_DURATION, None) + if not supported_features & SUPPORT_VOLUME_SET: + params.pop(ATTR_VOLUME_LEVEL, None) + + return params + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up siren devices.""" + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + async def async_handle_turn_on_service( + siren: SirenEntity, call: ServiceCall + ) -> None: + """Handle turning a siren on.""" + data = { + k: v + for k, v in call.data.items() + if k in (ATTR_TONE, ATTR_DURATION, ATTR_VOLUME_LEVEL) + } + await siren.async_turn_on( + **process_turn_on_params(siren, cast(SirenTurnOnServiceParameters, data)) + ) + + component.async_register_entity_service( + SERVICE_TURN_ON, TURN_ON_SCHEMA, async_handle_turn_on_service, [SUPPORT_TURN_ON] + ) + component.async_register_entity_service( + SERVICE_TURN_OFF, {}, "async_turn_off", [SUPPORT_TURN_OFF] + ) + component.async_register_entity_service( + SERVICE_TOGGLE, {}, "async_toggle", [SUPPORT_TURN_ON & SUPPORT_TURN_OFF] + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class SirenEntityDescription(ToggleEntityDescription): + """A class that describes siren entities.""" + + +class SirenEntity(ToggleEntity): + """Representation of a siren device.""" + + entity_description: SirenEntityDescription + _attr_available_tones: list[int | str] | None = None + + @final + @property + def capability_attributes(self) -> dict[str, Any] | None: + """Return capability attributes.""" + supported_features = self.supported_features or 0 + + if supported_features & SUPPORT_TONES and self.available_tones is not None: + return {ATTR_AVAILABLE_TONES: self.available_tones} + + return None + + @property + def available_tones(self) -> list[int | str] | None: + """ + Return a list of available tones. + + Requires SUPPORT_TONES. + """ + return self._attr_available_tones diff --git a/homeassistant/components/siren/const.py b/homeassistant/components/siren/const.py new file mode 100644 index 00000000000..2faab9ed8e8 --- /dev/null +++ b/homeassistant/components/siren/const.py @@ -0,0 +1,17 @@ +"""Constants for the siren component.""" + +from typing import Final + +DOMAIN: Final = "siren" + +ATTR_TONE: Final = "tone" + +ATTR_AVAILABLE_TONES: Final = "available_tones" +ATTR_DURATION: Final = "duration" +ATTR_VOLUME_LEVEL: Final = "volume_level" + +SUPPORT_TURN_ON: Final = 1 +SUPPORT_TURN_OFF: Final = 2 +SUPPORT_TONES: Final = 4 +SUPPORT_VOLUME_SET: Final = 8 +SUPPORT_DURATION: Final = 16 diff --git a/homeassistant/components/siren/manifest.json b/homeassistant/components/siren/manifest.json new file mode 100644 index 00000000000..454835c33b0 --- /dev/null +++ b/homeassistant/components/siren/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "siren", + "name": "Siren", + "documentation": "https://www.home-assistant.io/integrations/siren", + "codeowners": ["@home-assistant/core", "@raman325"], + "quality_scale": "internal" +} \ No newline at end of file diff --git a/homeassistant/components/siren/services.yaml b/homeassistant/components/siren/services.yaml new file mode 100644 index 00000000000..8c5ed3be974 --- /dev/null +++ b/homeassistant/components/siren/services.yaml @@ -0,0 +1,41 @@ +# Describes the format for available siren services + +turn_on: + description: Turn siren on. + target: + entity: + domain: siren + fields: + tone: + description: The tone to emit when turning the siren on. Must be supported by the integration. + example: fire + required: false + selector: + text: + volume_level: + description: The volume level of the noise to emit when turning the siren on. Must be supported by the integration. + example: 0.5 + required: false + selector: + number: + min: 0 + max: 1 + step: 0.05 + duration: + description: The duration in seconds of the noise to emit when turning the siren on. Must be supported by the integration. + example: 15 + required: false + selector: + text: + +turn_off: + description: Turn siren off. + target: + entity: + domain: siren + +toggle: + description: Toggles a siren. + target: + entity: + domain: siren diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index fd707f9dd96..5b6eae96a7e 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_MAC, CONF_NAME, + DEVICE_CLASS_TEMPERATURE, EVENT_HOMEASSISTANT_STOP, PERCENTAGE, STATE_UNKNOWN, @@ -90,6 +91,7 @@ class SkybeaconHumid(SensorEntity): class SkybeaconTemp(SensorEntity): """Representation of a Skybeacon temperature sensor.""" + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_unit_of_measurement = TEMP_CELSIUS def __init__(self, name, mon): diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 7e075fba38a..1e7eae145e3 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,5 +1,8 @@ """Binary sensor support for the Skybell HD Doorbell.""" +from __future__ import annotations + from datetime import timedelta +from typing import Any import voluptuous as vol @@ -8,6 +11,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_OCCUPANCY, PLATFORM_SCHEMA, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -16,19 +20,28 @@ from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice SCAN_INTERVAL = timedelta(seconds=10) -# Sensor types: Name, device_class, event -SENSOR_TYPES = { - "button": ["Button", DEVICE_CLASS_OCCUPANCY, "device:sensor:button"], - "motion": ["Motion", DEVICE_CLASS_MOTION, "device:sensor:motion"], + +BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { + "button": BinarySensorEntityDescription( + key="device:sensor:button", + name="Button", + device_class=DEVICE_CLASS_OCCUPANCY, + ), + "motion": BinarySensorEntityDescription( + key="device:sensor:motion", + name="Motion", + device_class=DEVICE_CLASS_MOTION, + ), } + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional( CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE ): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)] ), } ) @@ -38,42 +51,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the platform for a Skybell device.""" skybell = hass.data.get(SKYBELL_DOMAIN) - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - for device in skybell.get_devices(): - sensors.append(SkybellBinarySensor(device, sensor_type)) + binary_sensors = [ + SkybellBinarySensor(device, BINARY_SENSOR_TYPES[sensor_type]) + for device in skybell.get_devices() + for sensor_type in config[CONF_MONITORED_CONDITIONS] + ] - add_entities(sensors, True) + add_entities(binary_sensors, True) class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): """A binary sensor implementation for Skybell devices.""" - def __init__(self, device, sensor_type): + def __init__( + self, + device, + description: BinarySensorEntityDescription, + ): """Initialize a binary sensor for a Skybell device.""" super().__init__(device) - self._sensor_type = sensor_type - self._name = "{} {}".format( - self._device.name, SENSOR_TYPES[self._sensor_type][0] - ) - self._device_class = SENSOR_TYPES[self._sensor_type][1] - self._event = {} - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of the binary sensor.""" - return self._device_class + self.entity_description = description + self._attr_name = f"{self._device.name} {description.name}" + self._event: dict[Any, Any] = {} @property def extra_state_attributes(self): @@ -88,8 +87,8 @@ class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): """Get the latest data and updates the state.""" super().update() - event = self._device.latest(SENSOR_TYPES[self._sensor_type][2]) + event = self._device.latest(self.entity_description.key) - self._state = bool(event and event.get("id") != self._event.get("id")) + self._attr_is_on = bool(event and event.get("id") != self._event.get("id")) self._event = event or {} diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index de99a22f4c9..cee864911b4 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,9 +1,15 @@ """Sensor support for Skybell Doorbells.""" +from __future__ import annotations + from datetime import timedelta import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv @@ -11,8 +17,15 @@ from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice SCAN_INTERVAL = timedelta(seconds=30) -# Sensor types: Name, icon -SENSOR_TYPES = {"chime_level": ["Chime Level", "bell-ring"]} +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="chime_level", + name="Chime Level", + icon="mdi:bell-ring", + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -20,7 +33,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE ): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] + cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] ), } ) @@ -30,10 +43,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the platform for a Skybell device.""" skybell = hass.data.get(SKYBELL_DOMAIN) - sensors = [] - for sensor_type in config.get(CONF_MONITORED_CONDITIONS): - for device in skybell.get_devices(): - sensors.append(SkybellSensor(device, sensor_type)) + sensors = [ + SkybellSensor(device, description) + for device in skybell.get_devices() + for description in SENSOR_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] + ] add_entities(sensors, True) @@ -41,34 +56,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SkybellSensor(SkybellDevice, SensorEntity): """A sensor implementation for Skybell devices.""" - def __init__(self, device, sensor_type): + def __init__( + self, + device, + description: SensorEntityDescription, + ): """Initialize a sensor for a Skybell device.""" super().__init__(device) - self._sensor_type = sensor_type - self._icon = f"mdi:{SENSOR_TYPES[self._sensor_type][1]}" - self._name = "{} {}".format( - self._device.name, SENSOR_TYPES[self._sensor_type][0] - ) - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon + self.entity_description = description + self._attr_name = f"{self._device.name} {description.name}" def update(self): """Get the latest data and updates the state.""" super().update() - if self._sensor_type == "chime_level": - self._state = self._device.outdoor_chime_level + if self.entity_description.key == "chime_level": + self._attr_state = self._device.outdoor_chime_level diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 1ad13af9249..c842f0e91af 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,17 +1,30 @@ """Switch support for the Skybell HD Doorbell.""" +from __future__ import annotations + import voluptuous as vol -from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity +from homeassistant.components.switch import ( + PLATFORM_SCHEMA, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice -# Switch types: Name -SWITCH_TYPES = { - "do_not_disturb": ["Do Not Disturb"], - "motion_sensor": ["Motion Sensor"], -} +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="do_not_disturb", + name="Do Not Disturb", + ), + SwitchEntityDescription( + key="motion_sensor", + name="Motion Sensor", + ), +) +MONITORED_CONDITIONS: list[str] = [desc.key for desc in SWITCH_TYPES] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -19,7 +32,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE ): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SWITCH_TYPES)] + cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] ), } ) @@ -29,39 +42,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the platform for a Skybell device.""" skybell = hass.data.get(SKYBELL_DOMAIN) - sensors = [] - for switch_type in config.get(CONF_MONITORED_CONDITIONS): - for device in skybell.get_devices(): - sensors.append(SkybellSwitch(device, switch_type)) + switches = [ + SkybellSwitch(device, description) + for device in skybell.get_devices() + for description in SWITCH_TYPES + if description.key in config[CONF_MONITORED_CONDITIONS] + ] - add_entities(sensors, True) + add_entities(switches, True) class SkybellSwitch(SkybellDevice, SwitchEntity): """A switch implementation for Skybell devices.""" - def __init__(self, device, switch_type): + def __init__( + self, + device, + description: SwitchEntityDescription, + ): """Initialize a light for a Skybell device.""" super().__init__(device) - self._switch_type = switch_type - self._name = "{} {}".format( - self._device.name, SWITCH_TYPES[self._switch_type][0] - ) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name + self.entity_description = description + self._attr_name = f"{self._device.name} {description.name}" def turn_on(self, **kwargs): """Turn on the switch.""" - setattr(self._device, self._switch_type, True) + setattr(self._device, self.entity_description.key, True) def turn_off(self, **kwargs): """Turn off the switch.""" - setattr(self._device, self._switch_type, False) + setattr(self._device, self.entity_description.key, False) @property def is_on(self): """Return true if device is on.""" - return getattr(self._device, self._switch_type) + return getattr(self._device, self.entity_description.key) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index c2ca834b565..4dfacda266c 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -22,8 +22,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import ATTR_ICON, CONF_API_KEY, CONF_ICON, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, config_validation as cv -import homeassistant.helpers.template as template +from homeassistant.helpers import aiohttp_client, config_validation as cv, template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -147,20 +146,6 @@ def _async_sanitize_channel_names(channel_list: list[str]) -> list[str]: return [channel.lstrip("#") for channel in channel_list] -@callback -def _async_templatize_blocks(hass: HomeAssistant, value: Any) -> Any: - """Recursive template creator helper function.""" - if isinstance(value, list): - return [_async_templatize_blocks(hass, item) for item in value] - if isinstance(value, dict): - return { - key: _async_templatize_blocks(hass, item) for key, item in value.items() - } - - tmpl = template.Template(value, hass=hass) # type: ignore # no-untyped-call - return tmpl.async_render(parse_result=False) - - class SlackNotificationService(BaseNotificationService): """Define the Slack notification logic.""" @@ -315,9 +300,9 @@ class SlackNotificationService(BaseNotificationService): # Message Type 1: A text-only message if ATTR_FILE not in data: if ATTR_BLOCKS_TEMPLATE in data: - blocks = _async_templatize_blocks( - self._hass, data[ATTR_BLOCKS_TEMPLATE] - ) + value = cv.template_complex(data[ATTR_BLOCKS_TEMPLATE]) + template.attach(self._hass, value) + blocks = template.render_complex(value) elif ATTR_BLOCKS in data: blocks = data[ATTR_BLOCKS] else: diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 985a0506574..a462d0c854b 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.4"], + "requirements": ["pysma==0.6.5"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 3894f864ffb..6f3f7c2dca9 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -7,7 +7,11 @@ from typing import Any import pysma import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + STATE_CLASS_MEASUREMENT, + SensorEntity, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_HOST, @@ -16,6 +20,8 @@ from homeassistant.const import ( CONF_SENSORS, CONF_SSL, CONF_VERIFY_SSL, + DEVICE_CLASS_ENERGY, + ENERGY_KILO_WATT_HOUR, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -26,6 +32,7 @@ from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) +from homeassistant.util import dt as dt_util from .const import ( CONF_CUSTOM, @@ -157,6 +164,11 @@ class SMAsensor(CoordinatorEntity, SensorEntity): self._config_entry_unique_id = config_entry_unique_id self._device_info = device_info + if self.unit_of_measurement == ENERGY_KILO_WATT_HOUR: + self._attr_state_class = STATE_CLASS_MEASUREMENT + self._attr_device_class = DEVICE_CLASS_ENERGY + self._attr_last_reset = dt_util.utc_from_timestamp(0) + # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. self._sensor.enabled = False diff --git a/homeassistant/components/sma/translations/de.json b/homeassistant/components/sma/translations/de.json index c17271cae20..7ec8fe06d0f 100644 --- a/homeassistant/components/sma/translations/de.json +++ b/homeassistant/components/sma/translations/de.json @@ -20,7 +20,7 @@ "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "description": "Gib deine SMA-Ger\u00e4teinformationen ein.", - "title": "Richten Sie SMA Solar ein" + "title": "Richte SMA Solar ein" } } } diff --git a/homeassistant/components/sma/translations/fr.json b/homeassistant/components/sma/translations/fr.json index ab154fea3f8..e70401c87f5 100644 --- a/homeassistant/components/sma/translations/fr.json +++ b/homeassistant/components/sma/translations/fr.json @@ -1,17 +1,23 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Le flux de configuration est d\u00e9j\u00e0 en cours" }, "error": { - "cannot_retrieve_device_info": "Connexion r\u00e9ussie, mais impossible de r\u00e9cup\u00e9rer les informations sur l'appareil" + "cannot_connect": "\u00c9chec de connexion", + "cannot_retrieve_device_info": "Connexion r\u00e9ussie, mais impossible de r\u00e9cup\u00e9rer les informations sur l'appareil", + "invalid_auth": "Authentification invalide", + "unknown": "Erreur inattendue" }, "step": { "user": { "data": { "group": "Groupe", "host": "H\u00f4te ", - "password": "Mot de passe" + "password": "Mot de passe", + "ssl": "Utilise un certificat SSL", + "verify_ssl": "V\u00e9rifier le certificat SSL" }, "description": "Saisissez les informations relatives \u00e0 votre appareil SMA.", "title": "Configurer SMA Solar" diff --git a/homeassistant/components/sma/translations/hu.json b/homeassistant/components/sma/translations/hu.json new file mode 100644 index 00000000000..cab063cd077 --- /dev/null +++ b/homeassistant/components/sma/translations/hu.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "cannot_retrieve_device_info": "Sikeres csatlakoz\u00e1s, de nem tudja lek\u00e9rni az eszk\u00f6z adatait", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "step": { + "user": { + "data": { + "group": "Csoport", + "host": "Gazdag\u00e9p", + "password": "Jelsz\u00f3", + "ssl": "SSL tan\u00fas\u00edtv\u00e1nyt haszn\u00e1l", + "verify_ssl": "Ellen\u0151rizze az SSL tan\u00fas\u00edtv\u00e1nyt" + }, + "description": "Adja meg az SMA-eszk\u00f6z adatait.", + "title": "Az SMA Solar be\u00e1ll\u00edt\u00e1sa" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 024845a08fc..fb00886f1f6 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -1,6 +1,11 @@ """Support for monitoring a Smappee energy sensor.""" from homeassistant.components.sensor import SensorEntity -from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, VOLT +from homeassistant.const import ( + DEVICE_CLASS_POWER, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_WATT_HOUR, + POWER_WATT, +) from .const import DOMAIN @@ -93,7 +98,7 @@ VOLTAGE_SENSORS = { "phase_voltages_a": [ "Phase voltages - A", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_a", None, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], @@ -101,7 +106,7 @@ VOLTAGE_SENSORS = { "phase_voltages_b": [ "Phase voltages - B", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_b", None, ["TWO", "THREE_STAR", "THREE_DELTA"], @@ -109,7 +114,7 @@ VOLTAGE_SENSORS = { "phase_voltages_c": [ "Phase voltages - C", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "phase_voltage_c", None, ["THREE_STAR"], @@ -117,7 +122,7 @@ VOLTAGE_SENSORS = { "line_voltages_a": [ "Line voltages - A", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_a", None, ["ONE", "TWO", "THREE_STAR", "THREE_DELTA"], @@ -125,7 +130,7 @@ VOLTAGE_SENSORS = { "line_voltages_b": [ "Line voltages - B", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_b", None, ["TWO", "THREE_STAR", "THREE_DELTA"], @@ -133,7 +138,7 @@ VOLTAGE_SENSORS = { "line_voltages_c": [ "Line voltages - C", "mdi:flash", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "line_voltage_c", None, ["THREE_STAR", "THREE_DELTA"], @@ -149,38 +154,38 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for service_location in smappee_base.smappee.service_locations.values(): # Add all basic sensors (realtime values and aggregators) # Some are available in local only env - for sensor in TREND_SENSORS: - if not service_location.local_polling or TREND_SENSORS[sensor][5]: + for sensor, attributes in TREND_SENSORS.items(): + if not service_location.local_polling or attributes[5]: entities.append( SmappeeSensor( smappee_base=smappee_base, service_location=service_location, sensor=sensor, - attributes=TREND_SENSORS[sensor], + attributes=attributes, ) ) if service_location.has_reactive_value: - for reactive_sensor in REACTIVE_SENSORS: + for reactive_sensor, attributes in REACTIVE_SENSORS.items(): entities.append( SmappeeSensor( smappee_base=smappee_base, service_location=service_location, sensor=reactive_sensor, - attributes=REACTIVE_SENSORS[reactive_sensor], + attributes=attributes, ) ) # Add solar sensors (some are available in local only env) if service_location.has_solar_production: - for sensor in SOLAR_SENSORS: - if not service_location.local_polling or SOLAR_SENSORS[sensor][5]: + for sensor, attributes in SOLAR_SENSORS.items(): + if not service_location.local_polling or attributes[5]: entities.append( SmappeeSensor( smappee_base=smappee_base, service_location=service_location, sensor=sensor, - attributes=SOLAR_SENSORS[sensor], + attributes=attributes, ) ) diff --git a/homeassistant/components/smappee/translations/hu.json b/homeassistant/components/smappee/translations/hu.json index c2535713626..5d3e65bb6fc 100644 --- a/homeassistant/components/smappee/translations/hu.json +++ b/homeassistant/components/smappee/translations/hu.json @@ -9,6 +9,11 @@ }, "flow_title": "{name}", "step": { + "environment": { + "data": { + "environment": "K\u00f6rnyezet" + } + }, "local": { "data": { "host": "Hoszt" diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 231cfa95263..bc64b173f20 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -9,6 +9,7 @@ import logging from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from pysmartapp.event import EVENT_TYPE_DEVICE from pysmartthings import Attribute, Capability, SmartThings +from pysmartthings.device import DeviceEntity from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -412,7 +413,7 @@ class DeviceBroker: class SmartThingsEntity(Entity): """Defines a SmartThings entity.""" - def __init__(self, device): + def __init__(self, device: DeviceEntity) -> None: """Initialize the instance.""" self._device = device self._dispatcher_remove = None diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index a7e2926036c..cb8fa4bb6d2 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -3,18 +3,27 @@ from __future__ import annotations from collections import namedtuple from collections.abc import Sequence +from datetime import datetime from pysmartthings import Attribute, Capability +from pysmartthings.device import DeviceEntity -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity from homeassistant.const import ( AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_CO, + DEVICE_CLASS_CO2, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, LIGHT_LUX, MASS_KILOGRAMS, @@ -22,29 +31,29 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, VOLUME_CUBIC_METERS, ) +from homeassistant.util.dt import utc_from_timestamp from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN -Map = namedtuple("map", "attribute name default_unit device_class") +Map = namedtuple("map", "attribute name default_unit device_class state_class") CAPABILITY_TO_SENSORS = { Capability.activity_lighting_mode: [ - Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None) + Map(Attribute.lighting_mode, "Activity Lighting Mode", None, None, None) ], Capability.air_conditioner_mode: [ - Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None) + Map(Attribute.air_conditioner_mode, "Air Conditioner Mode", None, None, None) ], Capability.air_quality_sensor: [ - Map(Attribute.air_quality, "Air Quality", "CAQI", None) + Map(Attribute.air_quality, "Air Quality", "CAQI", None, STATE_CLASS_MEASUREMENT) ], - Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None)], - Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None)], + Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None)], + Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None, None)], Capability.battery: [ - Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY) + Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY, None) ], Capability.body_mass_index_measurement: [ Map( @@ -52,57 +61,80 @@ CAPABILITY_TO_SENSORS = { "Body Mass Index", f"{MASS_KILOGRAMS}/{AREA_SQUARE_METERS}", None, + STATE_CLASS_MEASUREMENT, ) ], Capability.body_weight_measurement: [ - Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None) + Map( + Attribute.body_weight_measurement, + "Body Weight", + MASS_KILOGRAMS, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.carbon_dioxide_measurement: [ Map( Attribute.carbon_dioxide, "Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO2, + STATE_CLASS_MEASUREMENT, ) ], Capability.carbon_monoxide_detector: [ - Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None) + Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None, None) ], Capability.carbon_monoxide_measurement: [ Map( Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", CONCENTRATION_PARTS_PER_MILLION, - None, + DEVICE_CLASS_CO, + STATE_CLASS_MEASUREMENT, ) ], Capability.dishwasher_operating_state: [ - Map(Attribute.machine_state, "Dishwasher Machine State", None, None), - Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None), + Map(Attribute.machine_state, "Dishwasher Machine State", None, None, None), + Map(Attribute.dishwasher_job_state, "Dishwasher Job State", None, None, None), Map( Attribute.completion_time, "Dishwasher Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], - Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None)], + Capability.dryer_mode: [Map(Attribute.dryer_mode, "Dryer Mode", None, None, None)], Capability.dryer_operating_state: [ - Map(Attribute.machine_state, "Dryer Machine State", None, None), - Map(Attribute.dryer_job_state, "Dryer Job State", None, None), + Map(Attribute.machine_state, "Dryer Machine State", None, None, None), + Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None), Map( Attribute.completion_time, "Dryer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], Capability.dust_sensor: [ - Map(Attribute.fine_dust_level, "Fine Dust Level", None, None), - Map(Attribute.dust_level, "Dust Level", None, None), + Map( + Attribute.fine_dust_level, + "Fine Dust Level", + None, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.dust_level, "Dust Level", None, None, STATE_CLASS_MEASUREMENT), ], Capability.energy_meter: [ - Map(Attribute.energy, "Energy Meter", ENERGY_KILO_WATT_HOUR, None) + Map( + Attribute.energy, + "Energy Meter", + ENERGY_KILO_WATT_HOUR, + DEVICE_CLASS_ENERGY, + STATE_CLASS_MEASUREMENT, + ) ], Capability.equivalent_carbon_dioxide_measurement: [ Map( @@ -110,6 +142,7 @@ CAPABILITY_TO_SENSORS = { "Equivalent Carbon Dioxide Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.formaldehyde_measurement: [ @@ -118,50 +151,95 @@ CAPABILITY_TO_SENSORS = { "Formaldehyde Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.gas_meter: [ - Map(Attribute.gas_meter, "Gas Meter", ENERGY_KILO_WATT_HOUR, None), - Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None), - Map(Attribute.gas_meter_time, "Gas Meter Time", None, DEVICE_CLASS_TIMESTAMP), - Map(Attribute.gas_meter_volume, "Gas Meter Volume", VOLUME_CUBIC_METERS, None), + Map( + Attribute.gas_meter, + "Gas Meter", + ENERGY_KILO_WATT_HOUR, + None, + STATE_CLASS_MEASUREMENT, + ), + Map(Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None), + Map( + Attribute.gas_meter_time, + "Gas Meter Time", + None, + DEVICE_CLASS_TIMESTAMP, + None, + ), + Map( + Attribute.gas_meter_volume, + "Gas Meter Volume", + VOLUME_CUBIC_METERS, + None, + STATE_CLASS_MEASUREMENT, + ), ], Capability.illuminance_measurement: [ - Map(Attribute.illuminance, "Illuminance", LIGHT_LUX, DEVICE_CLASS_ILLUMINANCE) + Map( + Attribute.illuminance, + "Illuminance", + LIGHT_LUX, + DEVICE_CLASS_ILLUMINANCE, + STATE_CLASS_MEASUREMENT, + ) ], Capability.infrared_level: [ - Map(Attribute.infrared_level, "Infrared Level", PERCENTAGE, None) + Map( + Attribute.infrared_level, + "Infrared Level", + PERCENTAGE, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.media_input_source: [ - Map(Attribute.input_source, "Media Input Source", None, None) + Map(Attribute.input_source, "Media Input Source", None, None, None) ], Capability.media_playback_repeat: [ - Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None) + Map(Attribute.playback_repeat_mode, "Media Playback Repeat", None, None, None) ], Capability.media_playback_shuffle: [ - Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None) + Map(Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None) ], Capability.media_playback: [ - Map(Attribute.playback_status, "Media Playback Status", None, None) + Map(Attribute.playback_status, "Media Playback Status", None, None, None) ], - Capability.odor_sensor: [Map(Attribute.odor_level, "Odor Sensor", None, None)], - Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None)], + Capability.odor_sensor: [ + Map(Attribute.odor_level, "Odor Sensor", None, None, None) + ], + Capability.oven_mode: [Map(Attribute.oven_mode, "Oven Mode", None, None, None)], Capability.oven_operating_state: [ - Map(Attribute.machine_state, "Oven Machine State", None, None), - Map(Attribute.oven_job_state, "Oven Job State", None, None), - Map(Attribute.completion_time, "Oven Completion Time", None, None), + Map(Attribute.machine_state, "Oven Machine State", None, None, None), + Map(Attribute.oven_job_state, "Oven Job State", None, None, None), + Map(Attribute.completion_time, "Oven Completion Time", None, None, None), ], Capability.oven_setpoint: [ - Map(Attribute.oven_setpoint, "Oven Set Point", None, None) + Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None) + ], + Capability.power_consumption_report: [], + Capability.power_meter: [ + Map( + Attribute.power, + "Power Meter", + POWER_WATT, + DEVICE_CLASS_POWER, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.power_source: [ + Map(Attribute.power_source, "Power Source", None, None, None) ], - Capability.power_meter: [Map(Attribute.power, "Power Meter", POWER_WATT, None)], - Capability.power_source: [Map(Attribute.power_source, "Power Source", None, None)], Capability.refrigeration_setpoint: [ Map( Attribute.refrigeration_setpoint, "Refrigeration Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.relative_humidity_measurement: [ @@ -170,6 +248,7 @@ CAPABILITY_TO_SENSORS = { "Relative Humidity Measurement", PERCENTAGE, DEVICE_CLASS_HUMIDITY, + STATE_CLASS_MEASUREMENT, ) ], Capability.robot_cleaner_cleaning_mode: [ @@ -178,25 +257,43 @@ CAPABILITY_TO_SENSORS = { "Robot Cleaner Cleaning Mode", None, None, + None, ) ], Capability.robot_cleaner_movement: [ - Map(Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None) + Map( + Attribute.robot_cleaner_movement, "Robot Cleaner Movement", None, None, None + ) ], Capability.robot_cleaner_turbo_mode: [ - Map(Attribute.robot_cleaner_turbo_mode, "Robot Cleaner Turbo Mode", None, None) + Map( + Attribute.robot_cleaner_turbo_mode, + "Robot Cleaner Turbo Mode", + None, + None, + None, + ) ], Capability.signal_strength: [ - Map(Attribute.lqi, "LQI Signal Strength", None, None), - Map(Attribute.rssi, "RSSI Signal Strength", None, None), + Map(Attribute.lqi, "LQI Signal Strength", None, None, STATE_CLASS_MEASUREMENT), + Map( + Attribute.rssi, + "RSSI Signal Strength", + None, + DEVICE_CLASS_SIGNAL_STRENGTH, + STATE_CLASS_MEASUREMENT, + ), + ], + Capability.smoke_detector: [ + Map(Attribute.smoke, "Smoke Detector", None, None, None) ], - Capability.smoke_detector: [Map(Attribute.smoke, "Smoke Detector", None, None)], Capability.temperature_measurement: [ Map( Attribute.temperature, "Temperature Measurement", None, DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, ) ], Capability.thermostat_cooling_setpoint: [ @@ -205,10 +302,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Cooling Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_fan_mode: [ - Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None) + Map(Attribute.thermostat_fan_mode, "Thermostat Fan Mode", None, None, None) ], Capability.thermostat_heating_setpoint: [ Map( @@ -216,10 +314,11 @@ CAPABILITY_TO_SENSORS = { "Thermostat Heating Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.thermostat_mode: [ - Map(Attribute.thermostat_mode, "Thermostat Mode", None, None) + Map(Attribute.thermostat_mode, "Thermostat Mode", None, None, None) ], Capability.thermostat_operating_state: [ Map( @@ -227,6 +326,7 @@ CAPABILITY_TO_SENSORS = { "Thermostat Operating State", None, None, + None, ) ], Capability.thermostat_setpoint: [ @@ -235,12 +335,13 @@ CAPABILITY_TO_SENSORS = { "Thermostat Setpoint", None, DEVICE_CLASS_TEMPERATURE, + None, ) ], Capability.three_axis: [], Capability.tv_channel: [ - Map(Attribute.tv_channel, "Tv Channel", None, None), - Map(Attribute.tv_channel_name, "Tv Channel Name", None, None), + Map(Attribute.tv_channel, "Tv Channel", None, None, None), + Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None), ], Capability.tvoc_measurement: [ Map( @@ -248,23 +349,39 @@ CAPABILITY_TO_SENSORS = { "Tvoc Measurement", CONCENTRATION_PARTS_PER_MILLION, None, + STATE_CLASS_MEASUREMENT, ) ], Capability.ultraviolet_index: [ - Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) + Map( + Attribute.ultraviolet_index, + "Ultraviolet Index", + None, + None, + STATE_CLASS_MEASUREMENT, + ) ], Capability.voltage_measurement: [ - Map(Attribute.voltage, "Voltage Measurement", VOLT, None) + Map( + Attribute.voltage, + "Voltage Measurement", + ELECTRIC_POTENTIAL_VOLT, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + ) + ], + Capability.washer_mode: [ + Map(Attribute.washer_mode, "Washer Mode", None, None, None) ], - Capability.washer_mode: [Map(Attribute.washer_mode, "Washer Mode", None, None)], Capability.washer_operating_state: [ - Map(Attribute.machine_state, "Washer Machine State", None, None), - Map(Attribute.washer_job_state, "Washer Job State", None, None), + Map(Attribute.machine_state, "Washer Machine State", None, None, None), + Map(Attribute.washer_job_state, "Washer Job State", None, None, None), Map( Attribute.completion_time, "Washer Completion Time", None, DEVICE_CLASS_TIMESTAMP, + None, ), ], } @@ -272,6 +389,13 @@ CAPABILITY_TO_SENSORS = { UNITS = {"C": TEMP_CELSIUS, "F": TEMP_FAHRENHEIT} THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"] +POWER_CONSUMPTION_REPORT_NAMES = [ + "energy", + "power", + "deltaEnergy", + "powerEnergy", + "energySaved", +] async def async_setup_entry(hass, config_entry, async_add_entities): @@ -287,16 +411,46 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for index in range(len(THREE_AXIS_NAMES)) ] ) + elif capability == Capability.power_consumption_report: + sensors.extend( + [ + SmartThingsPowerConsumptionSensor(device, report_name) + for report_name in POWER_CONSUMPTION_REPORT_NAMES + ] + ) else: maps = CAPABILITY_TO_SENSORS[capability] sensors.extend( [ SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, m.device_class + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, ) for m in maps ] ) + + if broker.any_assigned(device.device_id, "switch"): + for capability in (Capability.energy_meter, Capability.power_meter): + maps = CAPABILITY_TO_SENSORS[capability] + sensors.extend( + [ + SmartThingsSensor( + device, + m.attribute, + m.name, + m.default_unit, + m.device_class, + m.state_class, + ) + for m in maps + ] + ) + async_add_entities(sensors) @@ -311,14 +465,21 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Sensor.""" def __init__( - self, device, attribute: str, name: str, default_unit: str, device_class: str - ): + self, + device: DeviceEntity, + attribute: str, + name: str, + default_unit: str, + device_class: str, + state_class: str | None, + ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute self._name = name self._device_class = device_class self._default_unit = default_unit + self._attr_state_class = state_class @property def name(self) -> str: @@ -346,6 +507,13 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self._attribute == Attribute.energy: + return utc_from_timestamp(0) + return None + class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): """Define a SmartThings Three Axis Sensor.""" @@ -373,3 +541,59 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): return three_axis[self._index] except (TypeError, IndexError): return None + + +class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): + """Define a SmartThings Sensor.""" + + def __init__( + self, + device: DeviceEntity, + report_name: str, + ) -> None: + """Init the class.""" + super().__init__(device) + self.report_name = report_name + # This is an exception for STATE_CLASS_MEASUREMENT per @balloob + self._attr_state_class = STATE_CLASS_MEASUREMENT + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return f"{self._device.label} {self.report_name}" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self._device.device_id}.{self.report_name}" + + @property + def state(self): + """Return the state of the sensor.""" + value = self._device.status.attributes[Attribute.power_consumption].value + if value is None or value.get(self.report_name) is None: + return None + if self.report_name == "power": + return value[self.report_name] + return value[self.report_name] / 1000 + + @property + def device_class(self): + """Return the device class of the sensor.""" + if self.report_name == "power": + return DEVICE_CLASS_POWER + return DEVICE_CLASS_ENERGY + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + if self.report_name == "power": + return POWER_WATT + return ENERGY_KILO_WATT_HOUR + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + if self.report_name != "power": + return utc_from_timestamp(0) + return None diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 7b8364d9ba3..7e92ba4f663 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from pysmartthings import Attribute, Capability +from pysmartthings import Capability from homeassistant.components.switch import SwitchEntity @@ -48,16 +48,6 @@ class SmartThingsSwitch(SmartThingsEntity, SwitchEntity): # the entity state ahead of receiving the confirming push updates self.async_write_ha_state() - @property - def current_power_w(self): - """Return the current power usage in W.""" - return self._device.status.attributes[Attribute.power].value - - @property - def today_energy_kwh(self): - """Return the today total energy usage in kWh.""" - return self._device.status.attributes[Attribute.energy].value - @property def is_on(self) -> bool: """Return true if light is on.""" diff --git a/homeassistant/components/smartthings/translations/de.json b/homeassistant/components/smartthings/translations/de.json index 3c8d096403c..6cd7157b702 100644 --- a/homeassistant/components/smartthings/translations/de.json +++ b/homeassistant/components/smartthings/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "invalid_webhook_url": "Home Assistant ist nicht richtig konfiguriert, um Updates von SmartThings zu erhalten. Die Webhook-URL ist ung\u00fcltig: \n > {webhook_url} \n\n Bitte aktualisieren Sie Ihre Konfiguration gem\u00e4\u00df den [Anweisungen] ({component_url}), starten Sie den Home Assistant neu und versuchen Sie es erneut.", + "invalid_webhook_url": "Home Assistant ist nicht richtig konfiguriert, um Updates von SmartThings zu erhalten. Die Webhook-URL ist ung\u00fcltig: \n > {webhook_url} \n\nBitte aktualisiere deine Konfiguration gem\u00e4\u00df den [Anweisungen] ({component_url}), starte den Home Assistant neu und versuche es erneut.", "no_available_locations": "In Home Assistant sind keine SmartThings-Standorte zum Einrichten verf\u00fcgbar." }, "error": { @@ -9,7 +9,7 @@ "token_forbidden": "Das Token verf\u00fcgt nicht \u00fcber die erforderlichen OAuth-Bereiche.", "token_invalid_format": "Das Token muss im UID/GUID-Format vorliegen.", "token_unauthorized": "Das Token ist ung\u00fcltig oder nicht mehr autorisiert.", - "webhook_error": "SmartThings konnte die Webhook-URL nicht \u00fcberpr\u00fcfen. Bitte stellen Sie sicher, dass die Webhook-URL \u00fcber das Internet erreichbar ist, und versuchen Sie es erneut." + "webhook_error": "SmartThings konnte die Webhook-URL nicht \u00fcberpr\u00fcfen. Bitte stelle sicher, dass die Webhook-URL \u00fcber das Internet erreichbar ist, und versuche es erneut." }, "step": { "authorize": { @@ -17,20 +17,20 @@ }, "pat": { "data": { - "access_token": "Zugriffs-Token" + "access_token": "Zugangstoken" }, - "description": "Bitte geben Sie ein SmartThings [Personal Access Token] ({token_url}) ein, das gem\u00e4\u00df den [Anweisungen] ({component_url}) erstellt wurde. Dies wird zur Erstellung der Home Assistant-Integration in Ihrem SmartThings-Konto verwendet.", + "description": "Bitte gib ein SmartThings [Personal Access Token] ({token_url}) ein, das gem\u00e4\u00df den [Anweisungen] ({component_url}) erstellt wurde. Dies wird zur Erstellung der Home Assistant-Integration in deinem SmartThings-Konto verwendet.", "title": "Gib den pers\u00f6nlichen Zugangstoken an" }, "select_location": { "data": { "location_id": "Standort" }, - "description": "Bitte w\u00e4hlen Sie den SmartThings-Standort aus, den Sie Home Assistant hinzuf\u00fcgen m\u00f6chten. Wir \u00f6ffnen dann ein neues Fenster und bitten Sie, sich anzumelden und die Installation der Home Assistant-Integration am ausgew\u00e4hlten Standort zu autorisieren.", + "description": "Bitte w\u00e4hle den SmartThings-Standort aus, den du Home Assistant hinzuf\u00fcgen m\u00f6chtest. Wir \u00f6ffnen dann ein neues Fenster und bitten dich, sich anzumelden und die Installation der Home Assistant-Integration am ausgew\u00e4hlten Standort zu autorisieren.", "title": "Standort ausw\u00e4hlen" }, "user": { - "description": "SmartThings wird so konfiguriert, dass Push-Updates an Home Assistant gesendet werden an die URL: \n > {webhook_url} \n\nWenn dies nicht korrekt ist, aktualisieren Sie bitte Ihre Konfiguration, starten Sie Home Assistant neu und versuchen Sie es erneut.", + "description": "SmartThings wird so konfiguriert, dass Push-Updates an Home Assistant gesendet werden an die URL: \n > {webhook_url} \n\nWenn dies nicht korrekt ist, aktualisiere bitte deine Konfiguration, starte Home Assistant neu und versuche es erneut.", "title": "R\u00fcckruf-URL best\u00e4tigen" } } diff --git a/homeassistant/components/smartthings/translations/he.json b/homeassistant/components/smartthings/translations/he.json index 56fd6e0b4fb..db5bc91bf7b 100644 --- a/homeassistant/components/smartthings/translations/he.json +++ b/homeassistant/components/smartthings/translations/he.json @@ -19,8 +19,8 @@ } }, "user": { - "description": "\u05d4\u05d6\u05df SmartThings [\u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05d0\u05d9\u05e9\u05d9\u05ea] ( {token_url} ) \u05e9\u05e0\u05d5\u05e6\u05e8 \u05dc\u05e4\u05d9 [\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea] ( {component_url} ).", - "title": "\u05d4\u05d6\u05df \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d2\u05d9\u05e9\u05d4 \u05d0\u05d9\u05e9 " + "description": "SmartThings \u05d9\u05d5\u05d2\u05d3\u05e8 \u05dc\u05e9\u05dc\u05d5\u05d7 \u05e2\u05d3\u05db\u05d5\u05e0\u05d9 \u05d3\u05d7\u05d9\u05e4\u05d4 \u05dc-Home Assistant \u05d1:\n> {webhook_url}\n\n\u05d0\u05dd \u05d4\u05d3\u05d1\u05e8 \u05d0\u05d9\u05e0\u05d5 \u05e0\u05db\u05d5\u05df, \u05e0\u05d0 \u05dc\u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4, \u05d5\u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea Home Assistant \u05d5\u05dc\u05e0\u05e1\u05d5\u05ea \u05e9\u05d5\u05d1.", + "title": "\u05d0\u05d9\u05e9\u05d5\u05e8 \u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d4\u05ea\u05e7\u05e9\u05e8\u05d5\u05ea \u05d7\u05d6\u05e8\u05d4" } } } diff --git a/homeassistant/components/smartthings/translations/hu.json b/homeassistant/components/smartthings/translations/hu.json index 17c0a1a1b04..bd6808db322 100644 --- a/homeassistant/components/smartthings/translations/hu.json +++ b/homeassistant/components/smartthings/translations/hu.json @@ -20,6 +20,7 @@ } }, "user": { + "description": "K\u00e9rlek add meg a SmartThings [Personal Access Tokent]({token_url}), amit az [instrukci\u00f3k]({component_url}) alapj\u00e1n hozt\u00e1l l\u00e9tre.", "title": "Callback URL meger\u0151s\u00edt\u00e9se" } } diff --git a/homeassistant/components/smarttub/translations/de.json b/homeassistant/components/smarttub/translations/de.json index 4549360f761..a529f679868 100644 --- a/homeassistant/components/smarttub/translations/de.json +++ b/homeassistant/components/smarttub/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert", + "already_configured": "Konto wurde bereits konfiguriert", "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { diff --git a/homeassistant/components/smarttub/translations/fr.json b/homeassistant/components/smarttub/translations/fr.json index c6f3fdb8ce3..c660ca15e87 100644 --- a/homeassistant/components/smarttub/translations/fr.json +++ b/homeassistant/components/smarttub/translations/fr.json @@ -9,7 +9,8 @@ }, "step": { "reauth_confirm": { - "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte" + "description": "L'int\u00e9gration SmartTub doit r\u00e9-authentifier votre compte", + "title": "R\u00e9authentification de l'int\u00e9gration" }, "user": { "data": { diff --git a/homeassistant/components/smarttub/translations/hu.json b/homeassistant/components/smarttub/translations/hu.json index a2a4a9d706e..e9a45d3773f 100644 --- a/homeassistant/components/smarttub/translations/hu.json +++ b/homeassistant/components/smarttub/translations/hu.json @@ -8,6 +8,10 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { + "reauth_confirm": { + "description": "A SmartTub integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a fi\u00f3kj\u00e1t", + "title": "Az integr\u00e1ci\u00f3 \u00fajb\u00f3li azonos\u00edt\u00e1sa" + }, "user": { "data": { "email": "E-mail", diff --git a/homeassistant/components/smarttub/translations/id.json b/homeassistant/components/smarttub/translations/id.json index 672310e0d2f..bf32b29d1e7 100644 --- a/homeassistant/components/smarttub/translations/id.json +++ b/homeassistant/components/smarttub/translations/id.json @@ -8,6 +8,9 @@ "invalid_auth": "Autentikasi tidak valid" }, "step": { + "reauth_confirm": { + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "email": "Email", diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 29f0eb777ba..0586a5838fc 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -241,7 +241,7 @@ def _attach_file(atch_name, content_id): atch_name, ) attachment = MIMEApplication(file_bytes, Name=atch_name) - attachment["Content-Disposition"] = "attachment; " 'filename="%s"' % atch_name + attachment["Content-Disposition"] = f'attachment; filename="{atch_name}"' attachment.add_header("Content-ID", f"<{content_id}>") return attachment diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index 7210c8e5fd3..bc7807cb6db 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -240,7 +240,7 @@ class SnmpSwitch(SwitchEntity): await self._set(command) # User set vartype Null, command must be an empty string elif self._vartype == "Null": - await self._set(Null)("") + await self._set("") # user did not set vartype but command is digit: defaulting to Integer # or user did set vartype else: diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py index 81d9bc5aebe..872781bf19c 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -3,10 +3,16 @@ from datetime import timedelta import logging from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT -from homeassistant.const import ENERGY_WATT_HOUR, PERCENTAGE, POWER_WATT +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) from homeassistant.util import dt as dt_util -from .models import SolarEdgeSensor +from .models import SolarEdgeSensorEntityDescription DOMAIN = "solaredge" @@ -29,7 +35,7 @@ SCAN_INTERVAL = timedelta(minutes=15) # Supported overview sensors SENSOR_TYPES = [ - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="lifetime_energy", json_key="lifeTimeData", name="Lifetime energy", @@ -37,138 +43,143 @@ SENSOR_TYPES = [ last_reset=dt_util.utc_from_timestamp(0), state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_this_year", json_key="lastYearData", name="Energy this year", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_this_month", json_key="lastMonthData", name="Energy this month", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="energy_today", json_key="lastDayData", name="Energy today", entity_registry_enabled_default=False, icon="mdi:solar-power", unit_of_measurement=ENERGY_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="current_power", json_key="currentPower", name="Current Power", icon="mdi:solar-power", state_class=STATE_CLASS_MEASUREMENT, unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="site_details", name="Site details", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="meters", json_key="meters", name="Meters", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="sensors", json_key="sensors", name="Sensors", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="gateways", json_key="gateways", name="Gateways", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="batteries", json_key="batteries", name="Batteries", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="inverters", json_key="inverters", name="Inverters", entity_registry_enabled_default=False, ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="power_consumption", json_key="LOAD", name="Power Consumption", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="solar_power", json_key="PV", name="Solar Power", entity_registry_enabled_default=False, icon="mdi:solar-power", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="grid_power", json_key="GRID", name="Grid Power", entity_registry_enabled_default=False, icon="mdi:power-plug", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="storage_power", json_key="STORAGE", name="Storage Power", entity_registry_enabled_default=False, icon="mdi:car-battery", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="purchased_power", json_key="Purchased", name="Imported Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="production_power", json_key="Production", name="Production Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="consumption_power", json_key="Consumption", name="Consumption Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="selfconsumption_power", json_key="SelfConsumption", name="SelfConsumption Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="feedin_power", json_key="FeedIn", name="Exported Power", entity_registry_enabled_default=False, icon="mdi:flash", ), - SolarEdgeSensor( + SolarEdgeSensorEntityDescription( key="storage_level", json_key="STORAGE", name="Storage Level", diff --git a/homeassistant/components/solaredge/models.py b/homeassistant/components/solaredge/models.py index f91db9ee9ff..ce24d854aac 100644 --- a/homeassistant/components/solaredge/models.py +++ b/homeassistant/components/solaredge/models.py @@ -2,20 +2,12 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime + +from homeassistant.components.sensor import SensorEntityDescription @dataclass -class SolarEdgeSensor: - """Represents an SolarEdge Sensor.""" - - key: str - name: str +class SolarEdgeSensorEntityDescription(SensorEntityDescription): + """Sensor entity description for SolarEdge.""" json_key: str | None = None - device_class: str | None = None - entity_registry_enabled_default: bool = True - icon: str | None = None - last_reset: datetime | None = None - state_class: str | None = None - unit_of_measurement: str | None = None diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index 340b6e0c2c9..85e01a2d7ee 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -21,7 +21,7 @@ from .coordinator import ( SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, ) -from .models import SolarEdgeSensor +from .models import SolarEdgeSensorEntityDescription async def async_setup_entry( @@ -68,38 +68,41 @@ class SolarEdgeSensorFactory: self.services: dict[ str, tuple[ - type[SolarEdgeSensor | SolarEdgeOverviewSensor], SolarEdgeDataService + type[SolarEdgeSensorEntity | SolarEdgeOverviewSensor], + SolarEdgeDataService, ], ] = {"site_details": (SolarEdgeDetailsSensor, details)} - for key in [ + for key in ( "lifetime_energy", "energy_this_year", "energy_this_month", "energy_today", "current_power", - ]: + ): self.services[key] = (SolarEdgeOverviewSensor, overview) - for key in ["meters", "sensors", "gateways", "batteries", "inverters"]: + for key in ("meters", "sensors", "gateways", "batteries", "inverters"): self.services[key] = (SolarEdgeInventorySensor, inventory) - for key in ["power_consumption", "solar_power", "grid_power", "storage_power"]: + for key in ("power_consumption", "solar_power", "grid_power", "storage_power"): self.services[key] = (SolarEdgePowerFlowSensor, flow) - for key in ["storage_level"]: + for key in ("storage_level",): self.services[key] = (SolarEdgeStorageLevelSensor, flow) - for key in [ + for key in ( "purchased_power", "production_power", "feedin_power", "consumption_power", "selfconsumption_power", - ]: + ): self.services[key] = (SolarEdgeEnergyDetailsSensor, energy) - def create_sensor(self, sensor_type: SolarEdgeSensor) -> SolarEdgeSensor: + def create_sensor( + self, sensor_type: SolarEdgeSensorEntityDescription + ) -> SolarEdgeSensorEntityDescription: """Create and return a sensor based on the sensor_key.""" sensor_class, service = self.services[sensor_type.key] @@ -109,27 +112,21 @@ class SolarEdgeSensorFactory: class SolarEdgeSensorEntity(CoordinatorEntity, SensorEntity): """Abstract class for a solaredge sensor.""" + entity_description: SolarEdgeSensorEntityDescription + def __init__( self, platform_name: str, - sensor_type: SolarEdgeSensor, + description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the sensor.""" super().__init__(data_service.coordinator) self.platform_name = platform_name - self.sensor_type = sensor_type + self.entity_description = description self.data_service = data_service - self._attr_device_class = sensor_type.device_class - self._attr_entity_registry_enabled_default = ( - sensor_type.entity_registry_enabled_default - ) - self._attr_icon = sensor_type.icon - self._attr_last_reset = sensor_type.last_reset - self._attr_name = f"{platform_name} ({sensor_type.name})" - self._attr_state_class = sensor_type.state_class - self._attr_unit_of_measurement = sensor_type.unit_of_measurement + self._attr_name = f"{platform_name} ({description.name})" class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): @@ -138,7 +135,7 @@ class SolarEdgeOverviewSensor(SolarEdgeSensorEntity): @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeDetailsSensor(SolarEdgeSensorEntity): @@ -161,12 +158,12 @@ class SolarEdgeInventorySensor(SolarEdgeSensorEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): @@ -181,12 +178,12 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): @@ -197,23 +194,23 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): def __init__( self, platform_name: str, - sensor_type: SolarEdgeSensor, + description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, sensor_type, data_service) + super().__init__(platform_name, description, data_service) self._attr_unit_of_measurement = data_service.unit @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - return self.data_service.attributes.get(self.sensor_type.json_key) + return self.data_service.attributes.get(self.entity_description.json_key) @property def state(self) -> str | None: """Return the state of the sensor.""" - return self.data_service.data.get(self.sensor_type.json_key) + return self.data_service.data.get(self.entity_description.json_key) class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): @@ -224,7 +221,7 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): @property def state(self) -> str | None: """Return the state of the sensor.""" - attr = self.data_service.attributes.get(self.sensor_type.json_key) + attr = self.data_service.attributes.get(self.entity_description.json_key) if attr and "soc" in attr: return attr["soc"] return None diff --git a/homeassistant/components/solaredge/translations/de.json b/homeassistant/components/solaredge/translations/de.json index 20fc557e5c8..247187f35af 100644 --- a/homeassistant/components/solaredge/translations/de.json +++ b/homeassistant/components/solaredge/translations/de.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { - "already_configured": "Das Ger\u00e4t ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "could_not_connect": "Es konnte keine Verbindung zur Solaredge-API hergestellt werden", "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel", "site_not_active": "Die Seite ist nicht aktiv" diff --git a/homeassistant/components/solaredge/translations/hu.json b/homeassistant/components/solaredge/translations/hu.json index 8479c90f595..69e450f55ff 100644 --- a/homeassistant/components/solaredge/translations/hu.json +++ b/homeassistant/components/solaredge/translations/hu.json @@ -5,6 +5,7 @@ }, "error": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "could_not_connect": "Nem siker\u00fclt csatlakozni a solaredge API-hoz", "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs", "site_not_active": "Az oldal nem akt\u00edv" }, diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 441a1c39e08..3f159ce4480 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -13,13 +13,14 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_IP_ADDRESS, CONF_NAME, - ELECTRICAL_CURRENT_AMPERE, + DEVICE_CLASS_TEMPERATURE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, FREQUENCY_HERTZ, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -43,14 +44,27 @@ INVERTER_MODES = ( # Supported sensor types: # Key: ['json_key', 'name', unit, icon, attribute name] SENSOR_TYPES = { - "current_AC_voltage": ["gridvoltage", "Grid Voltage", VOLT, "mdi:current-ac", None], - "current_DC_voltage": ["dcvoltage", "DC Voltage", VOLT, "mdi:current-dc", None], + "current_AC_voltage": [ + "gridvoltage", + "Grid Voltage", + ELECTRIC_POTENTIAL_VOLT, + "mdi:current-ac", + None, + ], + "current_DC_voltage": [ + "dcvoltage", + "DC Voltage", + ELECTRIC_POTENTIAL_VOLT, + "mdi:current-dc", + None, + ], "current_frequency": [ "gridfrequency", "Grid Frequency", FREQUENCY_HERTZ, "mdi:current-ac", None, + None, ], "current_power": [ "currentPower", @@ -58,6 +72,7 @@ SENSOR_TYPES = { POWER_WATT, "mdi:solar-power", None, + None, ], "energy_this_month": [ "energyThisMonth", @@ -65,6 +80,7 @@ SENSOR_TYPES = { ENERGY_WATT_HOUR, "mdi:solar-power", None, + None, ], "energy_this_year": [ "energyThisYear", @@ -72,6 +88,7 @@ SENSOR_TYPES = { ENERGY_WATT_HOUR, "mdi:solar-power", None, + None, ], "energy_today": [ "energyToday", @@ -79,13 +96,15 @@ SENSOR_TYPES = { ENERGY_WATT_HOUR, "mdi:solar-power", None, + None, ], "inverter_temperature": [ "invertertemperature", "Inverter Temperature", TEMP_CELSIUS, - "mdi:thermometer", + None, "operating_mode", + DEVICE_CLASS_TEMPERATURE, ], "lifetime_energy": [ "energyTotal", @@ -93,6 +112,7 @@ SENSOR_TYPES = { ENERGY_WATT_HOUR, "mdi:solar-power", None, + None, ], "optimizer_connected": [ "optimizers", @@ -100,13 +120,15 @@ SENSOR_TYPES = { "optimizers", "mdi:solar-panel", "optimizers_connected", + None, ], "optimizer_current": [ "optimizercurrent", "Average Optimizer Current", - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, "mdi:solar-panel", None, + None, ], "optimizer_power": [ "optimizerpower", @@ -114,6 +136,7 @@ SENSOR_TYPES = { POWER_WATT, "mdi:solar-panel", None, + None, ], "optimizer_temperature": [ "optimizertemperature", @@ -121,13 +144,15 @@ SENSOR_TYPES = { TEMP_CELSIUS, "mdi:solar-panel", None, + DEVICE_CLASS_TEMPERATURE, ], "optimizer_voltage": [ "optimizervoltage", "Average Optimizer Voltage", - VOLT, + ELECTRIC_POTENTIAL_VOLT, "mdi:solar-panel", None, + None, ], } @@ -170,7 +195,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): TEMP_FAHRENHEIT, "mdi:thermometer", "operating_mode", - None, + DEVICE_CLASS_TEMPERATURE, ] try: @@ -181,6 +206,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): POWER_WATT, "mdi:arrow-collapse-down", None, + None, ] sensors["import_meter_reading"] = [ "totalEnergyimport", @@ -188,6 +214,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ENERGY_WATT_HOUR, "mdi:counter", None, + None, ] except IndexError: _LOGGER.debug("Import meter sensors are not created") @@ -200,6 +227,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): POWER_WATT, "mdi:arrow-expand-up", None, + None, ] sensors["export_meter_reading"] = [ "totalEnergyexport", @@ -207,6 +235,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ENERGY_WATT_HOUR, "mdi:counter", None, + None, ] except IndexError: _LOGGER.debug("Export meter sensors are not created") @@ -225,6 +254,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensor_info[2], sensor_info[3], sensor_info[4], + sensor_info[5], ) entities.append(sensor) @@ -234,7 +264,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SolarEdgeSensor(SensorEntity): """Representation of an SolarEdge Monitoring API sensor.""" - def __init__(self, platform_name, data, json_key, name, unit, icon, attr): + def __init__( + self, platform_name, data, json_key, name, unit, icon, attr, device_class + ): """Initialize the sensor.""" self._platform_name = platform_name self._data = data @@ -245,6 +277,7 @@ class SolarEdgeSensor(SensorEntity): self._unit_of_measurement = unit self._icon = icon self._attr = attr + self._attr_device_class = device_class @property def name(self): diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index dab844f86d6..0d989642d07 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,7 +1,12 @@ """Constants for the Solar-Log integration.""" from datetime import timedelta -from homeassistant.const import ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, VOLT +from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) DOMAIN = "solarlog" @@ -17,8 +22,8 @@ SENSOR_TYPES = { "time": ["TIME", "last update", None, "mdi:calendar-clock"], "power_ac": ["powerAC", "power AC", POWER_WATT, "mdi:solar-power"], "power_dc": ["powerDC", "power DC", POWER_WATT, "mdi:solar-power"], - "voltage_ac": ["voltageAC", "voltage AC", VOLT, "mdi:flash"], - "voltage_dc": ["voltageDC", "voltage DC", VOLT, "mdi:flash"], + "voltage_ac": ["voltageAC", "voltage AC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], + "voltage_dc": ["voltageDC", "voltage DC", ELECTRIC_POTENTIAL_VOLT, "mdi:flash"], "yield_day": ["yieldDAY", "yield day", ENERGY_KILO_WATT_HOUR, "mdi:solar-power"], "yield_yesterday": [ "yieldYESTERDAY", diff --git a/homeassistant/components/solarlog/translations/de.json b/homeassistant/components/solarlog/translations/de.json index 008e1058681..c2e38dcd940 100644 --- a/homeassistant/components/solarlog/translations/de.json +++ b/homeassistant/components/solarlog/translations/de.json @@ -11,7 +11,7 @@ "user": { "data": { "host": "Host", - "name": "Das Pr\u00e4fix, das f\u00fcr Ihre Solar-Log-Sensoren verwendet werden soll" + "name": "Das Pr\u00e4fix, das f\u00fcr deine Solar-Log-Sensoren verwendet werden soll" }, "title": "Definiere deine Solar-Log-Verbindung" } diff --git a/homeassistant/components/soma/translations/de.json b/homeassistant/components/soma/translations/de.json index 79cd15df3be..17cc5c1899d 100644 --- a/homeassistant/components/soma/translations/de.json +++ b/homeassistant/components/soma/translations/de.json @@ -4,7 +4,7 @@ "already_setup": "Du kannst nur ein einziges Soma-Konto konfigurieren.", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "connection_error": "Verbindung zu SOMA Connect fehlgeschlagen.", - "missing_configuration": "Die Soma-Komponente ist nicht konfiguriert. Bitte folgen Sie der Dokumentation.", + "missing_configuration": "Die Soma-Komponente ist nicht konfiguriert. Bitte folge der Dokumentation.", "result_error": "SOMA Connect hat mit einem Fehlerstatus geantwortet." }, "create_entry": { diff --git a/homeassistant/components/somfy_mylink/translations/de.json b/homeassistant/components/somfy_mylink/translations/de.json index d88d1320279..9fc1af6d92c 100644 --- a/homeassistant/components/somfy_mylink/translations/de.json +++ b/homeassistant/components/somfy_mylink/translations/de.json @@ -49,5 +49,5 @@ } } }, - "title": "" + "title": "Somfy MyLink" } \ No newline at end of file diff --git a/homeassistant/components/somfy_mylink/translations/hu.json b/homeassistant/components/somfy_mylink/translations/hu.json index 1cb4db9942a..5a2a1ee6ab5 100644 --- a/homeassistant/components/somfy_mylink/translations/hu.json +++ b/homeassistant/components/somfy_mylink/translations/hu.json @@ -13,8 +13,10 @@ "user": { "data": { "host": "Hoszt", - "port": "Port" - } + "port": "Port", + "system_id": "Rendszerazonos\u00edt\u00f3" + }, + "description": "A rendszerazonos\u00edt\u00f3t a MyLink alkalmaz\u00e1s Integr\u00e1ci\u00f3 r\u00e9sz\u00e9ben lehet beszerezni, b\u00e1rmely nem Cloud szolg\u00e1ltat\u00e1s kiv\u00e1laszt\u00e1s\u00e1val." } } }, @@ -24,15 +26,20 @@ }, "step": { "entity_config": { + "description": "Konfigur\u00e1lja az \u201e {entity_id} \u201d be\u00e1ll\u00edt\u00e1sait", "title": "Entit\u00e1s konfigur\u00e1l\u00e1sa" }, "init": { "data": { + "entity_id": "Konfigur\u00e1ljon egy adott entit\u00e1st.", "target_id": "Az \u00e1rny\u00e9kol\u00f3 be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa." }, "title": "Mylink be\u00e1ll\u00edt\u00e1sok konfigur\u00e1l\u00e1sa" }, "target_config": { + "data": { + "reverse": "A bor\u00edt\u00f3 megfordult" + }, "description": "A(z) `{target_name}` be\u00e1ll\u00edt\u00e1sainak konfigur\u00e1l\u00e1sa", "title": "MyLink \u00e1rny\u00e9kol\u00f3 konfigur\u00e1l\u00e1sa" } diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index b7636ca4db1..db82e729483 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -18,7 +18,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import ( CONF_BASE_PATH, @@ -75,7 +74,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: ConfigType | None = None) -> FlowResult: + async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle configuration by re-auth.""" self._reauth = True self._entry_data = dict(data) @@ -85,7 +84,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, user_input: ConfigType | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reauth dialog.""" if user_input is None: @@ -98,7 +97,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_user(self, user_input: ConfigType | None = None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initiated by the user.""" errors = {} @@ -171,7 +172,7 @@ class SonarrOptionsFlowHandler(OptionsFlow): """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: ConfigType | None = None): + async def async_step_init(self, user_input: dict[str, Any] | None = None): """Manage Sonarr options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index f1413aca52f..d173d42eaf7 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -162,7 +162,7 @@ class SonarrDiskspaceSensor(SonarrSensor): """Update entity.""" app = await self.sonarr.update() self._disks = app.disks - self._total_free = sum([disk.free for disk in self._disks]) + self._total_free = sum(disk.free for disk in self._disks) @property def extra_state_attributes(self) -> dict[str, Any] | None: diff --git a/homeassistant/components/sonarr/translations/de.json b/homeassistant/components/sonarr/translations/de.json index c7ca7bd692b..9779a985034 100644 --- a/homeassistant/components/sonarr/translations/de.json +++ b/homeassistant/components/sonarr/translations/de.json @@ -3,7 +3,7 @@ "abort": { "already_configured": "Der Dienst ist bereits konfiguriert", "reauth_successful": "Die erneute Authentifizierung war erfolgreich", - "unknown": "Unerwateter Fehler" + "unknown": "Unerwarteter Fehler" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -17,7 +17,7 @@ }, "user": { "data": { - "api_key": "API Schl\u00fcssel", + "api_key": "API-Schl\u00fcssel", "base_path": "Pfad zur API", "host": "Host", "port": "Port", diff --git a/homeassistant/components/sonarr/translations/hu.json b/homeassistant/components/sonarr/translations/hu.json index 160f9685308..6ebdb22404c 100644 --- a/homeassistant/components/sonarr/translations/hu.json +++ b/homeassistant/components/sonarr/translations/hu.json @@ -12,6 +12,7 @@ "flow_title": "{name}", "step": { "reauth_confirm": { + "description": "A Sonarr integr\u00e1ci\u00f3t manu\u00e1lisan kell hiteles\u00edteni a(z) {host}", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "user": { diff --git a/homeassistant/components/songpal/translations/de.json b/homeassistant/components/songpal/translations/de.json index b9b7fae3f28..97ba487f525 100644 --- a/homeassistant/components/songpal/translations/de.json +++ b/homeassistant/components/songpal/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "not_songpal_device": "Kein Songpal-Ger\u00e4t" }, "error": { @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "init": { - "description": "M\u00f6chten Sie {name} ({host}) einrichten?" + "description": "M\u00f6chtest du {name} ({host}) einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 3d810c7e1a3..f0219ea8cf0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -9,21 +9,17 @@ import logging import socket from urllib.parse import urlparse -import pysonos -from pysonos import events_asyncio -from pysonos.core import SoCo -from pysonos.exceptions import SoCoException +from soco import events_asyncio +import soco.config as soco_config +from soco.core import SoCo +from soco.exceptions import NotSupportedException, SoCoException import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOSTS, - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send @@ -35,7 +31,6 @@ from .const import ( DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, - SONOS_GROUP_UPDATE, SONOS_REBOOTED, SONOS_SEEN, UPNP_ST, @@ -92,6 +87,7 @@ class SonosData: self.alarms: dict[str, SonosAlarms] = {} self.topology_condition = asyncio.Condition() self.hosts_heartbeat = None + self.discovery_ignored: set[str] = set() self.discovery_known: set[str] = set() self.boot_counts: dict[str, int] = {} @@ -114,7 +110,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sonos from a config entry.""" - pysonos.config.EVENTS_MODULE = events_asyncio + soco_config.EVENTS_MODULE = events_asyncio if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData() @@ -126,7 +122,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: - pysonos.config.EVENT_ADVERTISE_IP = advertise_addr + soco_config.EVENT_ADVERTISE_IP = advertise_addr if deprecated_address := config.get(CONF_INTERFACE_ADDR): _LOGGER.warning( @@ -142,21 +138,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: - """Create a soco instance and return if successful.""" - try: - soco = pysonos.SoCo(ip_address) - # Ensure that the player is available and UID is cached - _ = soco.uid - _ = soco.volume - return soco - except (OSError, SoCoException) as ex: - _LOGGER.warning( - "Failed to connect to %s player '%s': %s", source.value, ip_address, ex - ) - return None - - class SonosDiscoveryManager: """Manage sonos discovery.""" @@ -170,9 +151,29 @@ class SonosDiscoveryManager: self.hosts = hosts self.discovery_lock = asyncio.Lock() + def _create_soco(self, ip_address: str, source: SoCoCreationSource) -> SoCo | None: + """Create a soco instance and return if successful.""" + if ip_address in self.data.discovery_ignored: + return None + + try: + soco = SoCo(ip_address) + # Ensure that the player is available and UID is cached + uid = soco.uid + _ = soco.volume + return soco + except NotSupportedException as exc: + _LOGGER.debug("Device %s is not supported, ignoring: %s", uid, exc) + self.data.discovery_ignored.add(ip_address) + except (OSError, SoCoException) as ex: + _LOGGER.warning( + "Failed to connect to %s player '%s': %s", source.value, ip_address, ex + ) + return None + async def _async_stop_event_listener(self, event: Event) -> None: await asyncio.gather( - *[speaker.async_unsubscribe() for speaker in self.data.discovered.values()], + *(speaker.async_unsubscribe() for speaker in self.data.discovered.values()), return_exceptions=True, ) if events_asyncio.event_listener: @@ -190,10 +191,10 @@ class SonosDiscoveryManager: _LOGGER.debug("Adding new speaker: %s", speaker_info) speaker = SonosSpeaker(self.hass, soco, speaker_info) self.data.discovered[soco.uid] = speaker - for coordinator, coord_dict in [ + for coordinator, coord_dict in ( (SonosAlarms, self.data.alarms), (SonosFavorites, self.data.favorites), - ]: + ): if soco.household_id not in coord_dict: new_coordinator = coordinator(self.hass, soco.household_id) new_coordinator.setup(soco) @@ -218,7 +219,7 @@ class SonosDiscoveryManager: if known_uid: dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}") else: - soco = _create_soco(ip_addr, SoCoCreationSource.CONFIGURED) + soco = self._create_soco(ip_addr, SoCoCreationSource.CONFIGURED) if soco and soco.is_visible: self._discovered_player(soco) @@ -226,12 +227,8 @@ class SonosDiscoveryManager: DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts ) - @callback - def _async_signal_update_groups(self, _event): - async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE) - def _discovered_ip(self, ip_address): - soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED) + soco = self._create_soco(ip_address, SoCoCreationSource.DISCOVERED) if soco and soco.is_visible: self._discovered_player(soco) @@ -247,7 +244,7 @@ class SonosDiscoveryManager: if boot_seqnum and boot_seqnum > self.data.boot_counts[uid]: self.data.boot_counts[uid] = boot_seqnum if soco := await self.hass.async_add_executor_job( - _create_soco, discovered_ip, SoCoCreationSource.REBOOTED + self._create_soco, discovered_ip, SoCoCreationSource.REBOOTED ): async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco) else: @@ -261,11 +258,13 @@ class SonosDiscoveryManager: if uid.startswith("uuid:"): uid = uid[5:] self.async_discovered_player( - info, discovered_ip, uid, boot_seqnum, info.get("modelName") + "SSDP", info, discovered_ip, uid, boot_seqnum, info.get("modelName") ) @callback - def async_discovered_player(self, info, discovered_ip, uid, boot_seqnum, model): + def async_discovered_player( + self, source, info, discovered_ip, uid, boot_seqnum, model + ): """Handle discovery via ssdp or zeroconf.""" if model in DISCOVERY_IGNORED_MODELS: _LOGGER.debug("Ignoring device: %s", info) @@ -274,7 +273,7 @@ class SonosDiscoveryManager: boot_seqnum = int(boot_seqnum) self.data.boot_counts.setdefault(uid, boot_seqnum) if uid not in self.data.discovery_known: - _LOGGER.debug("New discovery uid=%s: %s", uid, info) + _LOGGER.debug("New %s discovery uid=%s: %s", source, uid, info) self.data.discovery_known.add(uid) asyncio.create_task( self._async_create_discovered_player(uid, discovered_ip, boot_seqnum) @@ -283,14 +282,9 @@ class SonosDiscoveryManager: async def setup_platforms_and_discovery(self): """Set up platforms and discovery.""" await asyncio.gather( - *[ + *( self.hass.config_entries.async_forward_entry_setup(self.entry, platform) for platform in PLATFORMS - ] - ) - self.entry.async_on_unload( - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_signal_update_groups ) ) self.entry.async_on_unload( diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index 98e4b752cad..215e4fede32 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -5,9 +5,9 @@ from collections.abc import Iterator import logging from typing import Any -from pysonos import SoCo -from pysonos.alarms import Alarm, get_alarms -from pysonos.exceptions import SoCoException +from soco import SoCo +from soco.alarms import Alarm, get_alarms +from soco.exceptions import SoCoException from homeassistant.helpers.dispatcher import async_dispatcher_send diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 1ba750c24be..3bbdf2d9a26 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,7 +1,7 @@ """Config flow for SONOS.""" import logging -import pysonos +import soco from homeassistant import config_entries from homeassistant.const import CONF_HOST @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) async def _async_has_devices(hass: HomeAssistant) -> bool: """Return if there are devices that can be discovered.""" - result = await hass.async_add_executor_job(pysonos.discover) + result = await hass.async_add_executor_job(soco.discover) return bool(result) @@ -42,15 +42,9 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): boot_seqnum = properties.get("bootseq") model = properties.get("model") uid = hostname_to_uid(hostname) - _LOGGER.debug( - "Calling async_discovered_player for %s with uid=%s and boot_seqnum=%s", - host, - uid, - boot_seqnum, - ) if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): discovery_manager.async_discovered_player( - properties, host, uid, boot_seqnum, model + "Zeroconf", properties, host, uid, boot_seqnum, model ) return await self.async_step_discovery(discovery_info) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index aca4b9b39ae..88b71066486 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -140,7 +140,6 @@ SONOS_CREATE_BATTERY = "sonos_create_battery" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" SONOS_ENTITY_CREATED = "sonos_entity_created" SONOS_POLL_UPDATE = "sonos_poll_update" -SONOS_GROUP_UPDATE = "sonos_group_update" SONOS_ALARMS_UPDATED = "sonos_alarms_updated" SONOS_FAVORITES_UPDATED = "sonos_favorites_updated" SONOS_STATE_UPDATED = "sonos_state_updated" diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index a2b0d7c5a64..dadec82a939 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -4,8 +4,9 @@ from __future__ import annotations import datetime import logging -from pysonos.core import SoCo -from pysonos.exceptions import SoCoException +import soco.config as soco_config +from soco.core import SoCo +from soco.exceptions import SoCoException import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import ( @@ -65,10 +66,14 @@ class SonosEntity(Entity): async def async_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" if not self.speaker.subscriptions_failed: + if soco_config.EVENT_ADVERTISE_IP: + listener_msg = f"{self.speaker.subscription_address} (advertising as {soco_config.EVENT_ADVERTISE_IP})" + else: + listener_msg = self.speaker.subscription_address _LOGGER.warning( - "%s cannot reach [%s], falling back to polling, functionality may be limited", + "%s cannot reach %s, falling back to polling, functionality may be limited", self.speaker.zone_name, - self.speaker.subscription_address, + listener_msg, ) self.speaker.subscriptions_failed = True await self.speaker.async_unsubscribe() diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 25fc58ebba2..9695265b24d 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -5,9 +5,9 @@ from collections.abc import Iterator import logging from typing import Any -from pysonos import SoCo -from pysonos.data_structures import DidlFavorite -from pysonos.exceptions import SoCoException +from soco import SoCo +from soco.data_structures import DidlFavorite +from soco.exceptions import SoCoException from homeassistant.helpers.dispatcher import async_dispatcher_send diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 675a3e8e9f2..0854361cd79 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -5,7 +5,7 @@ import functools as ft import logging from typing import Any, Callable -from pysonos.exceptions import SoCoException, SoCoUPnPException +from soco.exceptions import SoCoException, SoCoUPnPException from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index d24ab40b3db..da964e93984 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Coroutine import logging from typing import Any -from pysonos import SoCo +from soco import SoCo from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index a35faee4ad6..4ce5623ac38 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["pysonos==0.0.53"], + "requirements": ["soco==0.23.2"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index a4cc6e175ec..0948e971baf 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -6,8 +6,8 @@ import logging from typing import Any import urllib.parse -from pysonos import alarms -from pysonos.core import ( +from soco import alarms +from soco.core import ( MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, PLAY_MODE_BY_MEANING, @@ -120,6 +120,7 @@ ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" ATTR_MASTER = "master" ATTR_WITH_GROUP = "with_group" ATTR_BUTTONS_ENABLED = "buttons_enabled" +ATTR_CROSSFADE = "crossfade" ATTR_NIGHT_SOUND = "night_sound" ATTR_SPEECH_ENHANCE = "speech_enhance" ATTR_QUEUE_POSITION = "queue_position" @@ -231,6 +232,7 @@ async def async_setup_entry( SERVICE_SET_OPTION, { vol.Optional(ATTR_BUTTONS_ENABLED): cv.boolean, + vol.Optional(ATTR_CROSSFADE): cv.boolean, vol.Optional(ATTR_NIGHT_SOUND): cv.boolean, vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean, vol.Optional(ATTR_STATUS_LIGHT): cv.boolean, @@ -609,6 +611,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): def set_option( self, buttons_enabled: bool | None = None, + crossfade: bool | None = None, night_sound: bool | None = None, speech_enhance: bool | None = None, status_light: bool | None = None, @@ -617,6 +620,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if buttons_enabled is not None: self.soco.buttons_enabled = buttons_enabled + if crossfade is not None: + self.soco.cross_fade = crossfade + if night_sound is not None and self.speaker.night_mode is not None: self.soco.night_mode = night_sound diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml index 365bdc29b37..76bc656f990 100644 --- a/homeassistant/components/sonos/services.yaml +++ b/homeassistant/components/sonos/services.yaml @@ -100,6 +100,12 @@ set_option: example: "true" selector: boolean: + crossfade: + name: Crossfade + description: Enable crossfade on the device + example: "true" + selector: + boolean: night_sound: name: Night sound description: Enable Night Sound mode diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 19f65f963c3..2ebef334873 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -11,13 +11,13 @@ from typing import Any, Callable import urllib.parse import async_timeout -from pysonos.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo -from pysonos.data_structures import DidlAudioBroadcast, DidlPlaylistContainer -from pysonos.events_base import Event as SonosEvent, SubscriptionBase -from pysonos.exceptions import SoCoException -from pysonos.music_library import MusicLibrary -from pysonos.plugins.sharelink import ShareLinkPlugin -from pysonos.snapshot import Snapshot +from soco.core import MUSIC_SRC_LINE_IN, MUSIC_SRC_RADIO, MUSIC_SRC_TV, SoCo +from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer +from soco.events_base import Event as SonosEvent, SubscriptionBase +from soco.exceptions import SoCoException, SoCoUPnPException +from soco.music_library import MusicLibrary +from soco.plugins.sharelink import ShareLinkPlugin +from soco.snapshot import Snapshot from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -25,6 +25,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as ent_reg from homeassistant.helpers.dispatcher import ( async_dispatcher_send, @@ -46,7 +47,6 @@ from .const import ( SONOS_CREATE_BATTERY, SONOS_CREATE_MEDIA_PLAYER, SONOS_ENTITY_CREATED, - SONOS_GROUP_UPDATE, SONOS_POLL_UPDATE, SONOS_REBOOTED, SONOS_SEEN, @@ -206,11 +206,6 @@ class SonosSpeaker: f"{SONOS_ENTITY_CREATED}-{self.soco.uid}", self.async_handle_new_entity, ) - self._group_dispatcher = dispatcher_connect( - self.hass, - SONOS_GROUP_UPDATE, - self.async_update_groups, - ) self._seen_dispatcher = dispatcher_connect( self.hass, f"{SONOS_SEEN}-{self.soco.uid}", self.async_seen ) @@ -352,7 +347,7 @@ class SonosSpeaker: """Cancel all subscriptions.""" _LOGGER.debug("Unsubscribing from events for %s", self.zone_name) await asyncio.gather( - *[subscription.unsubscribe() for subscription in self._subscriptions], + *(subscription.unsubscribe() for subscription in self._subscriptions), return_exceptions=True, ) self._subscriptions = [] @@ -465,7 +460,6 @@ class SonosSpeaker: self.soco = soco was_available = self.available - _LOGGER.debug("Async seen: %s, was_available: %s", self.soco, was_available) if self._seen_timer: self._seen_timer() @@ -478,6 +472,12 @@ class SonosSpeaker: self.async_write_entity_states() return + _LOGGER.debug( + "%s [%s] was not available, setting up", + self.zone_name, + self.soco.ip_address, + ) + self._poll_timer = self.hass.helpers.event.async_track_time_interval( partial( async_dispatcher_send, @@ -612,22 +612,18 @@ class SonosSpeaker: # # Group management # - def update_groups(self, event: SonosEvent | None = None) -> None: - """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.add_job(coro) # type: ignore + def update_groups(self) -> None: + """Update group topology when polling.""" + self.hass.add_job(self.create_update_groups_coro()) @callback - def async_update_groups(self, event: SonosEvent | None = None) -> None: + def async_update_groups(self, event: SonosEvent) -> None: """Handle callback for topology change event.""" - coro = self.create_update_groups_coro(event) - if coro: - self.hass.async_add_job(coro) # type: ignore + if not hasattr(event, "zone_player_uui_ds_in_group"): + return None + self.hass.async_add_job(self.create_update_groups_coro(event)) - def create_update_groups_coro( - self, event: SonosEvent | None = None - ) -> Coroutine | None: + def create_update_groups_coro(self, event: SonosEvent | None = None) -> Coroutine: """Handle callback for topology change event.""" def _get_soco_group() -> list[str]: @@ -646,7 +642,7 @@ class SonosSpeaker: return [coordinator_uid] + slave_uids - async def _async_extract_group(event: SonosEvent) -> list[str]: + async def _async_extract_group(event: SonosEvent | None) -> list[str]: """Extract group layout from a topology event.""" group = event and event.zone_player_uui_ds_in_group if group: @@ -658,6 +654,10 @@ class SonosSpeaker: @callback def _async_regroup(group: list[str]) -> None: """Rebuild internal group layout.""" + if group == [self.soco.uid] and self.sonos_group == [self]: + # Skip updating existing single speakers in polling mode + return + entity_registry = ent_reg.async_get(self.hass) sonos_group = [] sonos_group_entities = [] @@ -671,6 +671,11 @@ class SonosSpeaker: ) sonos_group_entities.append(entity_id) + if self.sonos_group_entities == sonos_group_entities: + # Useful in polling mode for speakers with stereo pairs or surrounds + # as those "invisible" speakers will bypass the single speaker check + return + self.coordinator = None self.sonos_group = sonos_group self.sonos_group_entities = sonos_group_entities @@ -684,7 +689,9 @@ class SonosSpeaker: slave.sonos_group_entities = sonos_group_entities slave.async_write_entity_states() - async def _async_handle_group_event(event: SonosEvent) -> None: + _LOGGER.debug("Regrouped %s: %s", self.zone_name, self.sonos_group_entities) + + async def _async_handle_group_event(event: SonosEvent | None) -> None: """Get async lock and handle event.""" async with self.hass.data[DATA_SONOS].topology_condition: @@ -695,9 +702,6 @@ class SonosSpeaker: self.hass.data[DATA_SONOS].topology_condition.notify_all() - if event and not hasattr(event, "zone_player_uui_ds_in_group"): - return None - return _async_handle_group_event(event) @soco_error() @@ -804,27 +808,58 @@ class SonosSpeaker: """Restore snapshots for all the speakers.""" def _restore_groups( - speakers: list[SonosSpeaker], with_group: bool + speakers: set[SonosSpeaker], with_group: bool ) -> list[list[SonosSpeaker]]: """Pause all current coordinators and restore groups.""" for speaker in (s for s in speakers if s.is_coordinator): - if speaker.media.playback_status == SONOS_STATE_PLAYING: - speaker.soco.pause() + if ( + speaker.media.playback_status == SONOS_STATE_PLAYING + and "Pause" in speaker.soco.available_actions + ): + try: + speaker.soco.pause() + except SoCoUPnPException as exc: + _LOGGER.debug( + "Pause failed during restore of %s: %s", + speaker.zone_name, + speaker.soco.available_actions, + exc_info=exc, + ) groups = [] + if not with_group: + return groups - if with_group: - # Unjoin slaves first to prevent inheritance of queues - for speaker in [s for s in speakers if not s.is_coordinator]: - if speaker.snapshot_group != speaker.sonos_group: - speaker.unjoin() + # Unjoin non-coordinator speakers not contained in the desired snapshot group + # + # If a coordinator is unjoined from its group, another speaker from the group + # will inherit the coordinator's playqueue and its own playqueue will be lost + speakers_to_unjoin = set() + for speaker in speakers: + if speaker.sonos_group == speaker.snapshot_group: + continue - # Bring back the original group topology - for speaker in (s for s in speakers if s.snapshot_group): - assert speaker.snapshot_group is not None - if speaker.snapshot_group[0] == speaker: + speakers_to_unjoin.update( + { + s + for s in speaker.sonos_group[1:] + if s not in speaker.snapshot_group + } + ) + + for speaker in speakers_to_unjoin: + speaker.unjoin() + + # Bring back the original group topology + for speaker in (s for s in speakers if s.snapshot_group): + assert speaker.snapshot_group is not None + if speaker.snapshot_group[0] == speaker: + if ( + speaker.snapshot_group != speaker.sonos_group + and speaker.snapshot_group != [speaker] + ): speaker.join(speaker.snapshot_group) - groups.append(speaker.snapshot_group.copy()) + groups.append(speaker.snapshot_group.copy()) return groups @@ -838,6 +873,11 @@ class SonosSpeaker: # Find all affected players speakers_set = {s for s in speakers if s.soco_snapshot} + if missing_snapshots := set(speakers) - speakers_set: + raise HomeAssistantError( + f"Restore failed, speakers are missing snapshots: {[s.zone_name for s in missing_snapshots]}" + ) + if with_group: for speaker in [s for s in speakers_set if s.snapshot_group]: assert speaker.snapshot_group is not None diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 795eded6ec1..482780453af 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations import datetime import logging -from pysonos.exceptions import SoCoException, SoCoUPnPException +from soco.exceptions import SoCoException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.const import ATTR_TIME diff --git a/homeassistant/components/sonos/translations/ar.json b/homeassistant/components/sonos/translations/ar.json new file mode 100644 index 00000000000..8917893b0a1 --- /dev/null +++ b/homeassistant/components/sonos/translations/ar.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0625\u0639\u062f\u0627\u062f Sonos\u061f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/translations/ca.json b/homeassistant/components/sonos/translations/ca.json index 4f9995c6c11..f0dbf0e2f0c 100644 --- a/homeassistant/components/sonos/translations/ca.json +++ b/homeassistant/components/sonos/translations/ca.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "No s'han trobat dispositius a la xarxa", + "not_sonos_device": "El dispositiu descobert no \u00e9s un dispositiu Sonos", "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." }, "step": { diff --git a/homeassistant/components/sonos/translations/de.json b/homeassistant/components/sonos/translations/de.json index 5d66c168116..0c799072010 100644 --- a/homeassistant/components/sonos/translations/de.json +++ b/homeassistant/components/sonos/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "not_sonos_device": "Erkanntes Ger\u00e4t ist kein Sonos-Ger\u00e4t", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/sonos/translations/es.json b/homeassistant/components/sonos/translations/es.json index 41d84a932ee..3560280c90e 100644 --- a/homeassistant/components/sonos/translations/es.json +++ b/homeassistant/components/sonos/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "No se encontraron dispositivos en la red", + "not_sonos_device": "El dispositivo descubierto no es un dispositivo Sonos", "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos." }, "step": { diff --git a/homeassistant/components/sonos/translations/et.json b/homeassistant/components/sonos/translations/et.json index 75f24b9b171..d330cd76cb0 100644 --- a/homeassistant/components/sonos/translations/et.json +++ b/homeassistant/components/sonos/translations/et.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "V\u00f5rgust ei leitud seadmeid", + "not_sonos_device": "Avastatud seade ei ole Sonose seade", "single_instance_allowed": "Juba seadistatud, lubatud on ainult \u00fcks sidumine." }, "step": { diff --git a/homeassistant/components/sonos/translations/fr.json b/homeassistant/components/sonos/translations/fr.json index 2bae0a69826..50a6086e2e8 100644 --- a/homeassistant/components/sonos/translations/fr.json +++ b/homeassistant/components/sonos/translations/fr.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.", + "not_sonos_device": "L'appareil d\u00e9couvert n'est pas un appareil Sonos", "single_instance_allowed": "Une seule configuration de Sonos est n\u00e9cessaire." }, "step": { diff --git a/homeassistant/components/sonos/translations/he.json b/homeassistant/components/sonos/translations/he.json index 91cbe81a2a6..878c14a5119 100644 --- a/homeassistant/components/sonos/translations/he.json +++ b/homeassistant/components/sonos/translations/he.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Sonos \u05d1\u05e8\u05e9\u05ea.", - "single_instance_allowed": "\u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Sonos \u05e0\u05d7\u05d5\u05e6\u05d4." + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { "confirm": { diff --git a/homeassistant/components/sonos/translations/hu.json b/homeassistant/components/sonos/translations/hu.json index 2123ec520f7..a928f97b3d6 100644 --- a/homeassistant/components/sonos/translations/hu.json +++ b/homeassistant/components/sonos/translations/hu.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", + "not_sonos_device": "A felfedezett eszk\u00f6z nem Sonos-eszk\u00f6z", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, "step": { diff --git a/homeassistant/components/sonos/translations/it.json b/homeassistant/components/sonos/translations/it.json index 1a646649a1b..e10ba5079b6 100644 --- a/homeassistant/components/sonos/translations/it.json +++ b/homeassistant/components/sonos/translations/it.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Nessun dispositivo trovato sulla rete", + "not_sonos_device": "Il dispositivo rilevato non \u00e8 un dispositivo Sonos", "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." }, "step": { diff --git a/homeassistant/components/sonos/translations/nl.json b/homeassistant/components/sonos/translations/nl.json index 42298f0b4f7..0147c5c5382 100644 --- a/homeassistant/components/sonos/translations/nl.json +++ b/homeassistant/components/sonos/translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Geen apparaten gevonden op het netwerk", + "not_sonos_device": "Gevonden apparaat is geen Sonos-apparaat", "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." }, "step": { diff --git a/homeassistant/components/sonos/translations/pl.json b/homeassistant/components/sonos/translations/pl.json index a8ee3fa57ac..67f9241f222 100644 --- a/homeassistant/components/sonos/translations/pl.json +++ b/homeassistant/components/sonos/translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "not_sonos_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Sonos", "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." }, "step": { diff --git a/homeassistant/components/sonos/translations/ru.json b/homeassistant/components/sonos/translations/ru.json index f0b6ca6b6bf..b5e9d9b35d6 100644 --- a/homeassistant/components/sonos/translations/ru.json +++ b/homeassistant/components/sonos/translations/ru.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "not_sonos_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 Sonos.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." }, "step": { diff --git a/homeassistant/components/sonos/translations/zh-Hant.json b/homeassistant/components/sonos/translations/zh-Hant.json index 31a9d3ce950..08434f16c15 100644 --- a/homeassistant/components/sonos/translations/zh-Hant.json +++ b/homeassistant/components/sonos/translations/zh-Hant.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "not_sonos_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Sonos \u88dd\u7f6e", "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" }, "step": { diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 71e51c0959d..b049b3a2d2c 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -1,19 +1,22 @@ """Support for testing internet speed via Speedtest.net.""" +from __future__ import annotations + from datetime import timedelta import logging import speedtest import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_STARTED, ) -from homeassistant.core import CoreState, callback +from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -22,6 +25,7 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_SERVER, DOMAIN, + PLATFORMS, SENSOR_TYPES, SPEED_TEST_SERVICE, ) @@ -51,10 +55,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["sensor"] - -def server_id_valid(server_id): +def server_id_valid(server_id: str) -> bool: """Check if server_id is valid.""" try: api = speedtest.Speedtest() @@ -65,7 +67,7 @@ def server_id_valid(server_id): return True -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Import integration from config.""" if DOMAIN in config: hass.async_create_task( @@ -76,7 +78,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Speedtest.net component.""" coordinator = SpeedTestDataCoordinator(hass, config_entry) await coordinator.async_setup() @@ -88,11 +90,9 @@ async def async_setup_entry(hass, config_entry): ) await coordinator.async_refresh() - if not config_entry.options[CONF_MANUAL]: + if not config_entry.options.get(CONF_MANUAL, False): if hass.state == CoreState.running: await _enable_scheduled_speedtests() - if not coordinator.last_update_success: - raise ConfigEntryNotReady else: # Running a speed test during startup can prevent # integrations from being able to setup because it @@ -108,12 +108,10 @@ async def async_setup_entry(hass, config_entry): return True -async def async_unload_entry(hass, config_entry): +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload SpeedTest Entry from config_entry.""" hass.services.async_remove(DOMAIN, SPEED_TEST_SERVICE) - hass.data[DOMAIN].async_unload() - unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) @@ -125,13 +123,12 @@ async def async_unload_entry(hass, config_entry): class SpeedTestDataCoordinator(DataUpdateCoordinator): """Get the latest data from speedtest.net.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the data object.""" self.hass = hass - self.config_entry = config_entry - self.api = None - self.servers = {} - self._unsub_update_listener = None + self.config_entry: ConfigEntry = config_entry + self.api: speedtest.Speedtest | None = None + self.servers: dict[str, dict] = {DEFAULT_SERVER: {}} super().__init__( self.hass, _LOGGER, @@ -141,51 +138,49 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): def update_servers(self): """Update list of test servers.""" - try: - server_list = self.api.get_servers() - except speedtest.ConfigRetrievalError: - _LOGGER.debug("Error retrieving server list") - return - - self.servers[DEFAULT_SERVER] = {} - for server in sorted( - server_list.values(), - key=lambda server: server[0]["country"] + server[0]["sponsor"], - ): - self.servers[ - f"{server[0]['country']} - {server[0]['sponsor']} - {server[0]['name']}" - ] = server[0] + test_servers = self.api.get_servers() + test_servers_list = [] + for servers in test_servers.values(): + for server in servers: + test_servers_list.append(server) + if test_servers_list: + for server in sorted( + test_servers_list, + key=lambda server: ( + server["country"], + server["name"], + server["sponsor"], + ), + ): + self.servers[ + f"{server['country']} - {server['sponsor']} - {server['name']}" + ] = server def update_data(self): """Get the latest data from speedtest.net.""" self.update_servers() - self.api.closest.clear() if self.config_entry.options.get(CONF_SERVER_ID): server_id = self.config_entry.options.get(CONF_SERVER_ID) self.api.get_servers(servers=[server_id]) - try: - self.api.get_best_server() - except speedtest.SpeedtestBestServerFailure as err: - raise UpdateFailed( - "Failed to retrieve best server for speedtest", err - ) from err - + best_server = self.api.get_best_server() _LOGGER.debug( "Executing speedtest.net speed test with server_id: %s", - self.api.best["id"], + best_server["id"], ) self.api.download() self.api.upload() return self.api.results.dict() - async def async_update(self, *_): + async def async_update(self) -> dict[str, str]: """Update Speedtest data.""" try: return await self.hass.async_add_executor_job(self.update_data) - except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers) as err: - raise UpdateFailed from err + except speedtest.NoMatchedServers as err: + raise UpdateFailed("Selected server is not found.") from err + except speedtest.SpeedtestException as err: + raise UpdateFailed(err) from err async def async_set_options(self): """Set options for entry.""" @@ -200,11 +195,12 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): self.config_entry, data=data, options=options ) - async def async_setup(self): + async def async_setup(self) -> None: """Set up SpeedTest.""" try: self.api = await self.hass.async_add_executor_job(speedtest.Speedtest) - except speedtest.ConfigRetrievalError as err: + await self.hass.async_add_executor_job(self.update_servers) + except speedtest.SpeedtestException as err: raise ConfigEntryNotReady from err async def request_update(call): @@ -213,24 +209,14 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator): await self.async_set_options() - await self.hass.async_add_executor_job(self.update_servers) - self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update) - self._unsub_update_listener = self.config_entry.add_update_listener( - options_updated_listener + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(options_updated_listener) ) - @callback - def async_unload(self): - """Unload the coordinator.""" - if not self._unsub_update_listener: - return - self._unsub_update_listener() - self._unsub_update_listener = None - -async def options_updated_listener(hass, entry): +async def options_updated_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" if entry.options[CONF_MANUAL]: hass.data[DOMAIN].update_interval = None diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 49654b6c02b..e5462aa9379 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -1,9 +1,14 @@ """Config flow for Speedtest.net.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import server_id_valid from .const import ( @@ -24,11 +29,15 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: """Get the options flow for this handler.""" return SpeedTestOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -59,14 +68,16 @@ class SpeedTestFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class SpeedTestOptionsFlowHandler(config_entries.OptionsFlow): """Handle SpeedTest options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - self._servers = {} + self._servers: dict = {} - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: server_name = user_input[CONF_SERVER_NAME] diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index 546c7db053b..04f3ea0cc55 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,32 +1,35 @@ """Consts used by Speedtest.net.""" +from typing import Final + from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS -DOMAIN = "speedtestdotnet" +DOMAIN: Final = "speedtestdotnet" -SPEED_TEST_SERVICE = "speedtest" -DATA_UPDATED = f"{DOMAIN}_data_updated" +SPEED_TEST_SERVICE: Final = "speedtest" -SENSOR_TYPES = { +SENSOR_TYPES: Final = { "ping": ["Ping", TIME_MILLISECONDS], "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], } -CONF_SERVER_NAME = "server_name" -CONF_SERVER_ID = "server_id" -CONF_MANUAL = "manual" +CONF_SERVER_NAME: Final = "server_name" +CONF_SERVER_ID: Final = "server_id" +CONF_MANUAL: Final = "manual" -ATTR_BYTES_RECEIVED = "bytes_received" -ATTR_BYTES_SENT = "bytes_sent" -ATTR_SERVER_COUNTRY = "server_country" -ATTR_SERVER_ID = "server_id" -ATTR_SERVER_NAME = "server_name" +ATTR_BYTES_RECEIVED: Final = "bytes_received" +ATTR_BYTES_SENT: Final = "bytes_sent" +ATTR_SERVER_COUNTRY: Final = "server_country" +ATTR_SERVER_ID: Final = "server_id" +ATTR_SERVER_NAME: Final = "server_name" -DEFAULT_NAME = "SpeedTest" -DEFAULT_SCAN_INTERVAL = 60 -DEFAULT_SERVER = "*Auto Detect" +DEFAULT_NAME: Final = "SpeedTest" +DEFAULT_SCAN_INTERVAL: Final = 60 +DEFAULT_SERVER: Final = "*Auto Detect" -ATTRIBUTION = "Data retrieved from Speedtest.net by Ookla" +ATTRIBUTION: Final = "Data retrieved from Speedtest.net by Ookla" -ICON = "mdi:speedometer" +ICON: Final = "mdi:speedometer" + +PLATFORMS: Final = ["sensor"] diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index e28aa0b2527..8dcc5bc3459 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -54,26 +54,28 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_name = f"{DEFAULT_NAME} {SENSOR_TYPES[sensor_type][0]}" self._attr_unit_of_measurement = SENSOR_TYPES[self.type][1] self._attr_unique_id = sensor_type + self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} @property - def extra_state_attributes(self) -> dict[str, Any] | None: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if not self.coordinator.data: - return None + if self.coordinator.data: + self._attrs.update( + { + ATTR_SERVER_NAME: self.coordinator.data["server"]["name"], + ATTR_SERVER_COUNTRY: self.coordinator.data["server"]["country"], + ATTR_SERVER_ID: self.coordinator.data["server"]["id"], + } + ) - attributes = { - ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_SERVER_NAME: self.coordinator.data["server"]["name"], - ATTR_SERVER_COUNTRY: self.coordinator.data["server"]["country"], - ATTR_SERVER_ID: self.coordinator.data["server"]["id"], - } + if self.type == "download": + self._attrs[ATTR_BYTES_RECEIVED] = self.coordinator.data[ + "bytes_received" + ] + elif self.type == "upload": + self._attrs[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] - if self.type == "download": - attributes[ATTR_BYTES_RECEIVED] = self.coordinator.data["bytes_received"] - elif self.type == "upload": - attributes[ATTR_BYTES_SENT] = self.coordinator.data["bytes_sent"] - - return attributes + return self._attrs async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -91,14 +93,12 @@ class SpeedtestSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self.async_on_remove(self.coordinator.async_add_listener(update)) self._update_state() - def _update_state(self) -> None: + def _update_state(self): """Update sensors state.""" - if not self.coordinator.data: - return - - if self.type == "ping": - self._attr_state = self.coordinator.data["ping"] - elif self.type == "download": - self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) - elif self.type == "upload": - self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) + if self.coordinator.data: + if self.type == "ping": + self._attr_state = self.coordinator.data["ping"] + elif self.type == "download": + self._attr_state = round(self.coordinator.data["download"] / 10 ** 6, 2) + elif self.type == "upload": + self._attr_state = round(self.coordinator.data["upload"] / 10 ** 6, 2) diff --git a/homeassistant/components/speedtestdotnet/strings.json b/homeassistant/components/speedtestdotnet/strings.json index cf3587af6c5..c4dad30cb09 100644 --- a/homeassistant/components/speedtestdotnet/strings.json +++ b/homeassistant/components/speedtestdotnet/strings.json @@ -6,8 +6,7 @@ } }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "wrong_server_id": "Server ID is not valid" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "options": { @@ -21,4 +20,4 @@ } } } -} \ No newline at end of file +} diff --git a/homeassistant/components/speedtestdotnet/translations/de.json b/homeassistant/components/speedtestdotnet/translations/de.json index f2635c19f03..eff51fab0b4 100644 --- a/homeassistant/components/speedtestdotnet/translations/de.json +++ b/homeassistant/components/speedtestdotnet/translations/de.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "single_instance_allowed": "Bereits konfiguriert. Es ist nur eine Konfiguration m\u00f6glich.", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich.", "wrong_server_id": "Server-ID ist ung\u00fcltig" }, "step": { "user": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } }, diff --git a/homeassistant/components/speedtestdotnet/translations/he.json b/homeassistant/components/speedtestdotnet/translations/he.json index 08506bf3437..ea3dd80aa94 100644 --- a/homeassistant/components/speedtestdotnet/translations/he.json +++ b/homeassistant/components/speedtestdotnet/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea.", + "wrong_server_id": "\u05de\u05d6\u05d4\u05d4 \u05d4\u05e9\u05e8\u05ea \u05d0\u05d9\u05e0\u05d5 \u05d7\u05d5\u05e7\u05d9" }, "step": { "user": { diff --git a/homeassistant/components/spider/const.py b/homeassistant/components/spider/const.py index 420767fd221..b8621262ed5 100644 --- a/homeassistant/components/spider/const.py +++ b/homeassistant/components/spider/const.py @@ -3,4 +3,4 @@ DOMAIN = "spider" DEFAULT_SCAN_INTERVAL = 300 -PLATFORMS = ["climate", "switch"] +PLATFORMS = ["climate", "switch", "sensor"] diff --git a/homeassistant/components/spider/sensor.py b/homeassistant/components/spider/sensor.py new file mode 100644 index 00000000000..998a9ff8eee --- /dev/null +++ b/homeassistant/components/spider/sensor.py @@ -0,0 +1,117 @@ +"""Support for Spider Powerplugs (energy & power).""" +from datetime import datetime + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + + +async def async_setup_entry(hass, config, async_add_entities): + """Initialize a Spider Power Plug.""" + api = hass.data[DOMAIN][config.entry_id] + entities = [] + + for entity in await hass.async_add_executor_job(api.get_power_plugs): + entities.append(SpiderPowerPlugEnergy(api, entity)) + entities.append(SpiderPowerPlugPower(api, entity)) + + async_add_entities(entities) + + +class SpiderPowerPlugEnergy(SensorEntity): + """Representation of a Spider Power Plug (energy).""" + + _attr_unit_of_measurement = ENERGY_KILO_WATT_HOUR + _attr_device_class = DEVICE_CLASS_ENERGY + _attr_state_class = STATE_CLASS_MEASUREMENT + + def __init__(self, api, power_plug) -> None: + """Initialize the Spider Power Plug.""" + self.api = api + self.power_plug = power_plug + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.power_plug.id)}, + "name": self.power_plug.name, + "manufacturer": self.power_plug.manufacturer, + "model": self.power_plug.model, + } + + @property + def unique_id(self) -> str: + """Return the ID of this sensor.""" + return f"{self.power_plug.id}_total_energy_today" + + @property + def name(self) -> str: + """Return the name of the sensor if any.""" + return f"{self.power_plug.name} Total Energy Today" + + @property + def state(self) -> float: + """Return todays energy usage in Kwh.""" + return round(self.power_plug.today_energy_consumption / 1000, 2) + + @property + def last_reset(self) -> datetime: + """Return the time when last reset; Every midnight.""" + return dt_util.as_utc( + dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) + ) + + def update(self) -> None: + """Get the latest data.""" + self.power_plug = self.api.get_power_plug(self.power_plug.id) + + +class SpiderPowerPlugPower(SensorEntity): + """Representation of a Spider Power Plug (power).""" + + _attr_device_class = DEVICE_CLASS_POWER + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_unit_of_measurement = POWER_WATT + + def __init__(self, api, power_plug) -> None: + """Initialize the Spider Power Plug.""" + self.api = api + self.power_plug = power_plug + + @property + def device_info(self) -> DeviceInfo: + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self.power_plug.id)}, + "name": self.power_plug.name, + "manufacturer": self.power_plug.manufacturer, + "model": self.power_plug.model, + } + + @property + def unique_id(self) -> str: + """Return the ID of this sensor.""" + return f"{self.power_plug.id}_power_consumption" + + @property + def name(self) -> str: + """Return the name of the sensor if any.""" + return f"{self.power_plug.name} Power Consumption" + + @property + def state(self) -> float: + """Return the current power usage in W.""" + return round(self.power_plug.current_energy_consumption) + + def update(self) -> None: + """Get the latest data.""" + self.power_plug = self.api.get_power_plug(self.power_plug.id) diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py index c9a99f3c205..ceb814b234a 100644 --- a/homeassistant/components/spider/switch.py +++ b/homeassistant/components/spider/switch.py @@ -43,16 +43,6 @@ class SpiderPowerPlug(SwitchEntity): """Return the name of the switch if any.""" return self.power_plug.name - @property - def current_power_w(self): - """Return the current power usage in W.""" - return round(self.power_plug.current_energy_consumption) - - @property - def today_energy_kwh(self): - """Return the current power usage in Kwh.""" - return round(self.power_plug.today_energy_consumption / 1000, 2) - @property def is_on(self): """Return true if switch is on. Standby is on.""" @@ -73,4 +63,4 @@ class SpiderPowerPlug(SwitchEntity): def update(self): """Get the latest data.""" - self.power_plug = self.api.get_power_plug(self.unique_id) + self.power_plug = self.api.get_power_plug(self.power_plug.id) diff --git a/homeassistant/components/spotify/translations/de.json b/homeassistant/components/spotify/translations/de.json index f92db780e82..799f3717b81 100644 --- a/homeassistant/components/spotify/translations/de.json +++ b/homeassistant/components/spotify/translations/de.json @@ -2,9 +2,9 @@ "config": { "abort": { "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", - "missing_configuration": "Die Spotify-Integration ist nicht konfiguriert. Bitte folgen Sie der Dokumentation.", + "missing_configuration": "Die Spotify-Integration ist nicht konfiguriert. Bitte folge der Dokumentation.", "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", - "reauth_account_mismatch": "Das Spotify-Konto, mit dem Sie sich authentifiziert haben, stimmt nicht mit dem Konto \u00fcberein, f\u00fcr das Sie sich erneut authentifizieren m\u00fcssen." + "reauth_account_mismatch": "Das Spotify-Konto, mit dem du dich authentifiziert hast, stimmt nicht mit dem Konto \u00fcberein, f\u00fcr das du dich erneut authentifizieren musst." }, "create_entry": { "default": "Erfolgreich mit Spotify authentifiziert." diff --git a/homeassistant/components/spotify/translations/hu.json b/homeassistant/components/spotify/translations/hu.json index 060aeffe8bd..8ffeadaf842 100644 --- a/homeassistant/components/spotify/translations/hu.json +++ b/homeassistant/components/spotify/translations/hu.json @@ -3,7 +3,8 @@ "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", "missing_configuration": "A Spotify integr\u00e1ci\u00f3 nincs konfigur\u00e1lva. K\u00e9rj\u00fck, k\u00f6vesse a dokument\u00e1ci\u00f3t.", - "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz." + "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", + "reauth_account_mismatch": "A Spotify-fi\u00f3kkal hiteles\u00edtett fi\u00f3k nem egyezik meg az \u00faj hiteles\u00edt\u00e9shez sz\u00fcks\u00e9ges fi\u00f3kkal." }, "create_entry": { "default": "A Spotify sikeresen hiteles\u00edtett." @@ -13,6 +14,7 @@ "title": "V\u00e1lassz hiteles\u00edt\u00e9si m\u00f3dszert" }, "reauth_confirm": { + "description": "A Spotify integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a Spotify fi\u00f3kot: {account}", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" } } diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json index 8dce61229a9..3dddd961194 100644 --- a/homeassistant/components/srp_energy/strings.json +++ b/homeassistant/components/srp_energy/strings.json @@ -1,5 +1,4 @@ { - "title": "SRP Energy", "config": { "step": { "user": { diff --git a/homeassistant/components/srp_energy/translations/de.json b/homeassistant/components/srp_energy/translations/de.json index 45f8ed451a4..a7992cac9b1 100644 --- a/homeassistant/components/srp_energy/translations/de.json +++ b/homeassistant/components/srp_energy/translations/de.json @@ -6,7 +6,7 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_account": "Die Konto-ID sollte eine 9-stellige Nummer sein", - "invalid_auth": "Ung\u00fcltige Anmeldung", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/srp_energy/translations/hu.json b/homeassistant/components/srp_energy/translations/hu.json index 0c3bdf29389..9ade185d831 100644 --- a/homeassistant/components/srp_energy/translations/hu.json +++ b/homeassistant/components/srp_energy/translations/hu.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_account": "A sz\u00e1mlaazonos\u00edt\u00f3nak 9 sz\u00e1mjegy\u0171nek kell lennie", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 9896ec4177e..31ebb0d1a92 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -256,9 +256,21 @@ class Scanner: self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start ) - await asyncio.gather( - *[listener.async_start() for listener in self._ssdp_listeners] + results = await asyncio.gather( + *(listener.async_start() for listener in self._ssdp_listeners), + return_exceptions=True, ) + failed_listeners = [] + for idx, result in enumerate(results): + if isinstance(result, Exception): + _LOGGER.warning( + "Failed to setup listener for %s: %s", + self._ssdp_listeners[idx].source_ip, + result, + ) + failed_listeners.append(self._ssdp_listeners[idx]) + for listener in failed_listeners: + self._ssdp_listeners.remove(listener) self._cancel_scan = async_track_time_interval( self.hass, self.async_scan, SCAN_INTERVAL ) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 4cb470be894..e7996befad3 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -1,10 +1,10 @@ """Reads vehicle status from StarLine API.""" from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE, SensorEntity from homeassistant.const import ( + ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, PERCENTAGE, TEMP_CELSIUS, - VOLT, VOLUME_LITERS, ) from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level @@ -14,7 +14,7 @@ from .const import DOMAIN from .entity import StarlineEntity SENSOR_TYPES = { - "battery": ["Battery", None, VOLT, None], + "battery": ["Battery", None, ELECTRIC_POTENTIAL_VOLT, None], "balance": ["Balance", None, None, "mdi:cash-multiple"], "ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], "etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None], diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py index 7ca3068f003..b4657838683 100644 --- a/homeassistant/components/statsd/__init__.py +++ b/homeassistant/components/statsd/__init__.py @@ -74,7 +74,7 @@ def setup(hass, config): if show_attribute_flag is True: if isinstance(_state, (float, int)): - statsd_client.gauge("%s.state" % state.entity_id, _state, sample_rate) + statsd_client.gauge(f"{state.entity_id}.state", _state, sample_rate) # Send attribute values for key, value in states.items(): diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 04be79e668e..69def43b2a2 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -1,8 +1,8 @@ """Provides the worker thread needed for processing streams.""" from __future__ import annotations -from collections import deque -from collections.abc import Iterator, Mapping +from collections import defaultdict, deque +from collections.abc import Generator, Iterator, Mapping from io import BytesIO import logging from threading import Event @@ -44,9 +44,9 @@ class SegmentBuffer: self._memory_file: BytesIO = cast(BytesIO, None) self._av_output: av.container.OutputContainer = None self._input_video_stream: av.video.VideoStream = None - self._input_audio_stream: Any | None = None # av.audio.AudioStream | None + self._input_audio_stream: av.audio.stream.AudioStream | None = None self._output_video_stream: av.video.VideoStream = None - self._output_audio_stream: Any | None = None # av.audio.AudioStream | None + self._output_audio_stream: av.audio.stream.AudioStream | None = None self._segment: Segment | None = None # the following 3 member variables are used for Part formation self._memory_file_pos: int = cast(int, None) @@ -117,7 +117,6 @@ class SegmentBuffer: # Check for end of segment if packet.stream == self._input_video_stream: - if ( packet.is_keyframe and (packet.dts - self._segment_start_dts) * packet.time_base @@ -201,7 +200,116 @@ class SegmentBuffer: self._memory_file.close() -def stream_worker( # noqa: C901 +class PeekIterator(Iterator): + """An Iterator that may allow multiple passes. + + This may be consumed like a normal Iterator, however also supports a + peek() method that buffers consumed items from the iterator. + """ + + def __init__(self, iterator: Iterator[av.Packet]) -> None: + """Initialize PeekIterator.""" + self._iterator = iterator + self._buffer: deque[av.Packet] = deque() + # A pointer to either _iterator or _buffer + self._next = self._iterator.__next__ + + def __iter__(self) -> Iterator: + """Return an iterator.""" + return self + + def __next__(self) -> av.Packet: + """Return and consume the next item available.""" + return self._next() + + def replace_underlying_iterator(self, new_iterator: Iterator) -> None: + """Replace the underlying iterator while preserving the buffer.""" + self._iterator = new_iterator + if self._next is not self._pop_buffer: + self._next = self._iterator.__next__ + + def _pop_buffer(self) -> av.Packet: + """Consume items from the buffer until exhausted.""" + if self._buffer: + return self._buffer.popleft() + # The buffer is empty, so change to consume from the iterator + self._next = self._iterator.__next__ + return self._next() + + def peek(self) -> Generator[av.Packet, None, None]: + """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. + self._next = self._pop_buffer + for packet in self._buffer: + yield packet + for packet in self._iterator: + self._buffer.append(packet) + yield packet + + +class TimestampValidator: + """Validate ordering of timestamps for packets in a stream.""" + + def __init__(self) -> None: + """Initialize the TimestampValidator.""" + # Decompression timestamp of last packet in each stream + self._last_dts: dict[av.stream.Stream, int | float] = defaultdict( + lambda: float("-inf") + ) + # Number of consecutive missing decompression timestamps + self._missing_dts = 0 + + def is_valid(self, packet: av.Packet) -> bool: + """Validate the packet timestamp based on ordering within the stream.""" + # Discard packets missing DTS. Terminate if too many are missing. + if packet.dts is None: + if self._missing_dts >= MAX_MISSING_DTS: + raise StopIteration( + f"No dts in {MAX_MISSING_DTS+1} consecutive packets" + ) + self._missing_dts += 1 + return False + self._missing_dts = 0 + # Discard when dts is not monotonic. Terminate if gap is too wide. + prev_dts = self._last_dts[packet.stream] + if packet.dts <= prev_dts: + gap = packet.time_base * (prev_dts - packet.dts) + if gap > MAX_TIMESTAMP_GAP: + raise StopIteration( + f"Timestamp overflow detected: last dts = {prev_dts}, dts = {packet.dts}" + ) + return False + self._last_dts[packet.stream] = packet.dts + return True + + +def is_keyframe(packet: av.Packet) -> Any: + """Return true if the packet is a keyframe.""" + return packet.is_keyframe + + +def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: + """Detect ADTS AAC, which is not supported by pyav.""" + if not audio_stream: + return False + for count, packet in enumerate(packets): + if count >= PACKETS_TO_WAIT_FOR_AUDIO: + # Some streams declare an audio stream and never send any packets + _LOGGER.warning("Audio stream not found") + break + if packet.stream == audio_stream: + # detect ADTS AAC and disable audio + if audio_stream.codec.name == "aac" and packet.size > 2: + with memoryview(packet) as packet_view: + if packet_view[0] == 0xFF and packet_view[1] & 0xF0 == 0xF0: + _LOGGER.warning("ADTS AAC detected - disabling audio stream") + return True + break + return False + + +def stream_worker( source: str, options: dict[str, str], segment_buffer: SegmentBuffer, @@ -232,142 +340,61 @@ def stream_worker( # noqa: C901 if audio_stream and audio_stream.profile is None: audio_stream = None - # Iterator for demuxing - container_packets: Iterator[av.Packet] - # The decoder timestamps of the latest packet in each stream we processed - last_dts = {video_stream: float("-inf"), audio_stream: float("-inf")} - # Keep track of consecutive packets without a dts to detect end of stream. - missing_dts = 0 - # The video dts at the beginning of the segment - segment_start_dts: int | None = None - # Because of problems 1 and 2 below, we need to store the first few packets and replay them - initial_packets: deque[av.Packet] = deque() + dts_validator = TimestampValidator() + container_packets = PeekIterator( + filter(dts_validator.is_valid, container.demux((video_stream, audio_stream))) + ) + + def is_video(packet: av.Packet) -> Any: + """Return true if the packet is for the video stream.""" + return packet.stream == video_stream # Have to work around two problems with RTSP feeds in ffmpeg # 1 - first frame has bad pts/dts https://trac.ffmpeg.org/ticket/5018 # 2 - seeking can be problematic https://trac.ffmpeg.org/ticket/7815 - - def peek_first_dts() -> bool: - """Initialize by peeking into the first few packets of the stream. - - Deal with problem #1 above (bad first packet pts/dts) by recalculating using pts/dts from second packet. - Also load the first video keyframe dts into segment_start_dts and check if the audio stream really exists. - """ - nonlocal segment_start_dts, audio_stream, container_packets - missing_dts = 0 - found_audio = False - try: - container_packets = container.demux((video_stream, audio_stream)) - first_packet: av.Packet | None = None - # Get to first video keyframe - while first_packet is None: - packet = next(container_packets) - if ( - packet.dts is None - ): # Allow MAX_MISSING_DTS packets with no dts, raise error on the next one - if missing_dts >= MAX_MISSING_DTS: - raise StopIteration( - f"Invalid data - got {MAX_MISSING_DTS+1} packets with missing DTS while initializing" - ) - missing_dts += 1 - continue - if packet.stream == audio_stream: - found_audio = True - elif packet.is_keyframe: # video_keyframe - first_packet = packet - initial_packets.append(packet) - # Get first_dts from subsequent frame to first keyframe - while segment_start_dts is None or ( - audio_stream - and not found_audio - and len(initial_packets) < PACKETS_TO_WAIT_FOR_AUDIO - ): - packet = next(container_packets) - if ( - packet.dts is None - ): # Allow MAX_MISSING_DTS packet with no dts, raise error on the next one - if missing_dts >= MAX_MISSING_DTS: - raise StopIteration( - f"Invalid data - got {MAX_MISSING_DTS+1} packets with missing DTS while initializing" - ) - missing_dts += 1 - continue - if packet.stream == audio_stream: - # detect ADTS AAC and disable audio - if audio_stream.codec.name == "aac" and packet.size > 2: - with memoryview(packet) as packet_view: - if packet_view[0] == 0xFF and packet_view[1] & 0xF0 == 0xF0: - _LOGGER.warning( - "ADTS AAC detected - disabling audio stream" - ) - container_packets = container.demux(video_stream) - audio_stream = None - continue - found_audio = True - elif ( - segment_start_dts is None - ): # This is the second video frame to calculate first_dts from - segment_start_dts = packet.dts - packet.duration - first_packet.pts = first_packet.dts = segment_start_dts - initial_packets.append(packet) - if audio_stream and not found_audio: - _LOGGER.warning( - "Audio stream not found" - ) # Some streams declare an audio stream and never send any packets - - except (av.AVError, StopIteration) as ex: - _LOGGER.error( - "Error demuxing stream while finding first packet: %s", str(ex) + # + # Use a peeking iterator to peek into the start of the stream, ensuring + # everything looks good, then go back to the start when muxing below. + try: + if audio_stream and unsupported_audio(container_packets.peek(), audio_stream): + audio_stream = None + container_packets.replace_underlying_iterator( + filter(dts_validator.is_valid, container.demux(video_stream)) ) - return False - return True - if not peek_first_dts(): + # Advance to the first keyframe for muxing, then rewind so the muxing + # loop below can consume. + first_keyframe = next( + filter(lambda pkt: is_keyframe(pkt) and is_video(pkt), container_packets) + ) + # Deal with problem #1 above (bad first packet pts/dts) by recalculating + # using pts/dts from second packet. Use the peek iterator to advance + # without consuming from container_packets. Skip over the first keyframe + # then use the duration from the second video packet to adjust dts. + next_video_packet = next(filter(is_video, container_packets.peek())) + # Since the is_valid filter has already been applied before the following + # adjustment, it does not filter out the case where the duration below is + # 0 and both the first_keyframe and next_video_packet end up with the same + # dts. Use "or 1" to deal with this. + start_dts = next_video_packet.dts - (next_video_packet.duration or 1) + first_keyframe.dts = first_keyframe.pts = start_dts + except (av.AVError, StopIteration) as ex: + _LOGGER.error("Error demuxing stream while finding first packet: %s", str(ex)) container.close() return segment_buffer.set_streams(video_stream, audio_stream) - assert isinstance(segment_start_dts, int) - segment_buffer.reset(segment_start_dts) + segment_buffer.reset(start_dts) + + # Mux the first keyframe, then proceed through the rest of the packets + segment_buffer.mux_packet(first_keyframe) while not quit_event.is_set(): try: - if len(initial_packets) > 0: - packet = initial_packets.popleft() - else: - packet = next(container_packets) - if packet.dts is None: - # Allow MAX_MISSING_DTS consecutive packets without dts. Terminate the stream on the next one. - if missing_dts >= MAX_MISSING_DTS: - raise StopIteration( - f"No dts in {MAX_MISSING_DTS+1} consecutive packets" - ) - missing_dts += 1 - continue - missing_dts = 0 + packet = next(container_packets) except (av.AVError, StopIteration) as ex: _LOGGER.error("Error demuxing stream: %s", str(ex)) break - - # Discard packet if dts is not monotonic - if packet.dts <= last_dts[packet.stream]: - if ( - packet.time_base * (last_dts[packet.stream] - packet.dts) - > MAX_TIMESTAMP_GAP - ): - _LOGGER.warning( - "Timestamp overflow detected: last dts %s, dts = %s, resetting stream", - last_dts[packet.stream], - packet.dts, - ) - break - continue - - # Update last_dts processed - last_dts[packet.stream] = packet.dts - - # Mux packets, and possibly write a segment to the output stream. - # This mutates packet timestamps and stream segment_buffer.mux_packet(packet) # Close stream diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 3994c9c6124..ff1d8b715d7 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -8,13 +8,13 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, LENGTH_KILOMETERS, LENGTH_MILES, PERCENTAGE, PRESSURE_HPA, TEMP_CELSIUS, TIME_MINUTES, - VOLT, VOLUME_GALLONS, VOLUME_LITERS, ) @@ -111,7 +111,7 @@ API_GEN_2_SENSORS = [ SENSOR_TYPE: "12V Battery Voltage", SENSOR_CLASS: DEVICE_CLASS_VOLTAGE, SENSOR_FIELD: sc.BATTERY_VOLTAGE, - SENSOR_UNITS: VOLT, + SENSOR_UNITS: ELECTRIC_POTENTIAL_VOLT, }, ] diff --git a/homeassistant/components/subaru/translations/de.json b/homeassistant/components/subaru/translations/de.json index ac953654565..4f10a4266ae 100644 --- a/homeassistant/components/subaru/translations/de.json +++ b/homeassistant/components/subaru/translations/de.json @@ -33,9 +33,9 @@ "step": { "init": { "data": { - "update_enabled": "Aktivieren Sie die Fahrzeugabfrage" + "update_enabled": "Aktiviere die Fahrzeugabfrage" }, - "description": "Wenn diese Option aktiviert ist, sendet die Fahrzeugabfrage alle 2 Stunden einen Fernbefehl an Ihr Fahrzeug, um neue Sensordaten zu erhalten. Ohne Fahrzeugabfrage werden neue Sensordaten nur empfangen, wenn das Fahrzeug automatisch Daten sendet (normalerweise nach dem Abstellen des Motors).", + "description": "Wenn diese Option aktiviert ist, sendet die Fahrzeugabfrage alle 2 Stunden einen Fernbefehl an dein Fahrzeug, um neue Sensordaten zu erhalten. Ohne Fahrzeugabfrage werden neue Sensordaten nur empfangen, wenn das Fahrzeug automatisch Daten sendet (normalerweise nach dem Abstellen des Motors).", "title": "Subaru Starlink Optionen" } } diff --git a/homeassistant/components/subaru/translations/hu.json b/homeassistant/components/subaru/translations/hu.json index 3be1db59239..a54ddd57c39 100644 --- a/homeassistant/components/subaru/translations/hu.json +++ b/homeassistant/components/subaru/translations/hu.json @@ -15,6 +15,7 @@ "data": { "pin": "PIN" }, + "description": "K\u00e9rj\u00fck, adja meg MySubaru PIN-k\u00f3dj\u00e1t\n MEGJEGYZ\u00c9S: A sz\u00e1ml\u00e1n szerepl\u0151 \u00f6sszes j\u00e1rm\u0171nek azonos PIN-k\u00f3ddal kell rendelkeznie", "title": "Subaru Starlink konfigur\u00e1ci\u00f3" }, "user": { @@ -23,6 +24,7 @@ "password": "Jelsz\u00f3", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" }, + "description": "K\u00e9rj\u00fck, adja meg MySubaru hiteles\u00edt\u0151 adatait\n MEGJEGYZ\u00c9S: A kezdeti be\u00e1ll\u00edt\u00e1s ak\u00e1r 30 m\u00e1sodpercet is ig\u00e9nybe vehetnek", "title": "Subaru Starlink konfigur\u00e1ci\u00f3" } } @@ -30,6 +32,10 @@ "options": { "step": { "init": { + "data": { + "update_enabled": "Enged\u00e9lyezze a j\u00e1rm\u0171 lek\u00e9rdez\u00e9s\u00e9t" + }, + "description": "Ha enged\u00e9lyezve van, a j\u00e1rm\u0171 lek\u00e9rdez\u00e9se 2 \u00f3r\u00e1nk\u00e9nt t\u00e1voli parancsot k\u00fcld a j\u00e1rm\u0171v\u00e9nek \u00faj \u00e9rz\u00e9kel\u0151 adatok megszerz\u00e9s\u00e9hez. A j\u00e1rm\u0171 lek\u00e9rdez\u00e9se n\u00e9lk\u00fcl az \u00faj \u00e9rz\u00e9kel\u0151adatok csak akkor \u00e9rkeznek, amikor a j\u00e1rm\u0171 automatikusan tov\u00e1bb\u00edtja az adatokat (\u00e1ltal\u00e1ban a motor le\u00e1ll\u00edt\u00e1sa ut\u00e1n).", "title": "Subaru Starlink be\u00e1ll\u00edt\u00e1sok" } } diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py index ca7b7378127..5a9ae733db0 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -175,7 +175,7 @@ class DeviceConnectivity(SurePetcareBinarySensor): """Get the latest data and update the state.""" surepy_entity = self._spc.states[self._id] state = surepy_entity.raw_data()["status"] - self._attr_is_on = self._attr_available = bool(self.state) + self._attr_is_on = self._attr_available = bool(state) if state: self._attr_extra_state_attributes = { "device_rssi": f'{state["signal"]["device_rssi"]:.2f}', diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 1ef48fed620..0abbc6d9f97 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -1,6 +1,7 @@ """Component to interface with switches that can be controlled remotely.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any, final @@ -19,7 +20,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -84,9 +85,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class SwitchEntityDescription(ToggleEntityDescription): + """A class that describes switch entities.""" + + class SwitchEntity(ToggleEntity): """Base class for switch entities.""" + entity_description: SwitchEntityDescription _attr_current_power_w: float | None = None _attr_today_energy_kwh: float | None = None diff --git a/homeassistant/components/switch/translations/he.json b/homeassistant/components/switch/translations/he.json index 23fbb7755f3..0b70a69350b 100644 --- a/homeassistant/components/switch/translations/he.json +++ b/homeassistant/components/switch/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "off": "\u05db\u05d1\u05d5\u05d9", - "on": "\u05d3\u05dc\u05d5\u05e7" + "on": "\u05de\u05d5\u05e4\u05e2\u05dc" } }, "title": "\u05de\u05ea\u05d2" diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index ef196220656..6c13067cd7f 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -1,85 +1,181 @@ """The Switcher integration.""" from __future__ import annotations -from asyncio import QueueEmpty, TimeoutError as Asyncio_TimeoutError, wait_for -from datetime import datetime, timedelta +from datetime import timedelta import logging -from aioswitcher.bridge import SwitcherV2Bridge +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 -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry, + update_coordinator, +) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import EventType from .const import ( CONF_DEVICE_PASSWORD, CONF_PHONE_ID, DATA_DEVICE, + DATA_DISCOVERY, DOMAIN, - SIGNAL_SWITCHER_DEVICE_UPDATE, + MAX_UPDATE_INTERVAL_SEC, + SIGNAL_DEVICE_ADD, ) +from .utils import async_start_bridge, async_stop_bridge + +PLATFORMS = ["switch", "sensor"] _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_PHONE_ID): cv.string, - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_DEVICE_PASSWORD): cv.string, - } - ) - }, +CCONFIG_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, ) async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the switcher component.""" - phone_id = config[DOMAIN][CONF_PHONE_ID] - device_id = config[DOMAIN][CONF_DEVICE_ID] - device_password = config[DOMAIN][CONF_DEVICE_PASSWORD] + hass.data.setdefault(DOMAIN, {}) - v2bridge = SwitcherV2Bridge(hass.loop, phone_id, device_id, device_password) + if DOMAIN not in config: + return True - await v2bridge.start() + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + ) + return True - async def async_stop_bridge(event: EventType) -> None: - """On Home Assistant stop, gracefully stop the bridge if running.""" - await v2bridge.stop() - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_bridge) - - try: - device_data = await wait_for(v2bridge.queue.get(), timeout=10.0) - except (Asyncio_TimeoutError, RuntimeError): - _LOGGER.exception("Failed to get response from device") - await v2bridge.stop() - return False - hass.data[DOMAIN] = {DATA_DEVICE: device_data} - - hass.async_create_task(async_load_platform(hass, "switch", DOMAIN, {}, config)) - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Switcher from a config entry.""" + hass.data[DOMAIN][DATA_DEVICE] = {} @callback - def device_updates(timestamp: datetime | None) -> None: - """Use for updating the device data from the queue.""" - if v2bridge.running: - try: - device_new_data = v2bridge.queue.get_nowait() - if device_new_data: - async_dispatcher_send( - hass, SIGNAL_SWITCHER_DEVICE_UPDATE, device_new_data - ) - except QueueEmpty: - pass + def on_device_data_callback(device: SwitcherBase) -> None: + """Use as a callback for device data.""" - async_track_time_interval(hass, device_updates, timedelta(seconds=4)) + # Existing device update device data + if device.device_id in hass.data[DOMAIN][DATA_DEVICE]: + wrapper: SwitcherDeviceWrapper = hass.data[DOMAIN][DATA_DEVICE][ + device.device_id + ] + wrapper.async_set_updated_data(device) + return + + # New device - create device + _LOGGER.info( + "Discovered Switcher device - id: %s, name: %s, type: %s (%s)", + device.device_id, + device.name, + device.device_type.value, + device.device_type.hex_rep, + ) + + wrapper = hass.data[DOMAIN][DATA_DEVICE][ + device.device_id + ] = SwitcherDeviceWrapper(hass, entry, device) + hass.async_create_task(wrapper.async_setup()) + + async def platforms_setup_task() -> None: + # Must be ready before dispatcher is called + for platform in PLATFORMS: + await hass.config_entries.async_forward_entry_setup(entry, platform) + + 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) + + await async_start_bridge(hass, on_device_data_callback) + + hass.async_create_task(platforms_setup_task()) + + @callback + async def stop_bridge(event: Event) -> None: + await async_stop_bridge(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_bridge) return True + + +class SwitcherDeviceWrapper(update_coordinator.DataUpdateCoordinator): + """Wrapper for a Switcher device with Home Assistant specific functions.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, device: SwitcherBase + ) -> None: + """Initialize the Switcher device wrapper.""" + super().__init__( + hass, + _LOGGER, + name=device.name, + update_interval=timedelta(seconds=MAX_UPDATE_INTERVAL_SEC), + ) + self.hass = hass + self.entry = entry + self.data = device + + async def _async_update_data(self) -> None: + """Mark device offline if no data.""" + raise update_coordinator.UpdateFailed( + f"Device {self.name} did not send update for {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] + + async def async_setup(self) -> None: + """Set up the wrapper.""" + dev_reg = await device_registry.async_get_registry(self.hass) + dev_reg.async_get_or_create( + config_entry_id=self.entry.entry_id, + connections={(device_registry.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: + """Unload a config entry.""" + await async_stop_bridge(hass) + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(DATA_DEVICE) + + return unload_ok diff --git a/homeassistant/components/switcher_kis/config_flow.py b/homeassistant/components/switcher_kis/config_flow.py new file mode 100644 index 00000000000..3c758715205 --- /dev/null +++ b/homeassistant/components/switcher_kis/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Switcher integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_DISCOVERY, DOMAIN +from .utils import async_discover_devices + + +class SwitcherFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Switcher config flow.""" + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """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 + ) -> FlowResult: + """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 + ) -> FlowResult: + """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={}) diff --git a/homeassistant/components/switcher_kis/const.py b/homeassistant/components/switcher_kis/const.py index acd6c070337..88b6e447446 100644 --- a/homeassistant/components/switcher_kis/const.py +++ b/homeassistant/components/switcher_kis/const.py @@ -1,20 +1,22 @@ """Constants for the Switcher integration.""" - DOMAIN = "switcher_kis" CONF_DEVICE_PASSWORD = "device_password" CONF_PHONE_ID = "phone_id" +DATA_BRIDGE = "bridge" DATA_DEVICE = "device" +DATA_DISCOVERY = "discovery" -SIGNAL_SWITCHER_DEVICE_UPDATE = "switcher_device_update" +DISCOVERY_TIME_SEC = 6 -ATTR_AUTO_OFF_SET = "auto_off_set" -ATTR_ELECTRIC_CURRENT = "electric_current" -ATTR_REMAINING_TIME = "remaining_time" +SIGNAL_DEVICE_ADD = "switcher_device_add" +# Services CONF_AUTO_OFF = "auto_off" CONF_TIMER_MINUTES = "timer_minutes" - SERVICE_SET_AUTO_OFF_NAME = "set_auto_off" SERVICE_TURN_ON_WITH_TIMER_NAME = "turn_on_with_timer" + +# Defines the maximum interval device must send an update before it marked unavailable +MAX_UPDATE_INTERVAL_SEC = 20 diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 84527954a2d..e982855e497 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -3,6 +3,7 @@ "name": "Switcher", "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "codeowners": ["@tomerfi","@thecode"], - "requirements": ["aioswitcher==1.2.3"], - "iot_class": "local_push" + "requirements": ["aioswitcher==2.0.4"], + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index 5b6b40a0e2d..705c6f0a2b6 100644 --- a/homeassistant/components/switcher_kis/sensor.py +++ b/homeassistant/components/switcher_kis/sensor.py @@ -3,8 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from aioswitcher.consts import WAITING_TEXT -from aioswitcher.devices import SwitcherV2Device +from aioswitcher.device import DeviceCategory from homeassistant.components.sensor import ( DEVICE_CLASS_CURRENT, @@ -12,13 +11,17 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import ELECTRICAL_CURRENT_AMPERE, POWER_WATT +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ELECTRIC_CURRENT_AMPERE, POWER_WATT from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType, StateType +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DATA_DEVICE, DOMAIN, SIGNAL_SWITCHER_DEVICE_UPDATE +from . import SwitcherDeviceWrapper +from .const import SIGNAL_DEVICE_ADD @dataclass @@ -31,7 +34,6 @@ class AttributeDescription: device_class: str | None = None state_class: str | None = None default_enabled: bool = True - default_value: float | int | str | None = None POWER_SENSORS = { @@ -40,14 +42,12 @@ POWER_SENSORS = { unit=POWER_WATT, device_class=DEVICE_CLASS_POWER, state_class=STATE_CLASS_MEASUREMENT, - default_value=0, ), "electric_current": AttributeDescription( name="Electric Current", - unit=ELECTRICAL_CURRENT_AMPERE, + unit=ELECTRIC_CURRENT_AMPERE, device_class=DEVICE_CLASS_CURRENT, state_class=STATE_CLASS_MEASUREMENT, - default_value=0.0, ), } @@ -55,77 +55,73 @@ TIME_SENSORS = { "remaining_time": AttributeDescription( name="Remaining Time", icon="mdi:av-timer", - default_value="00:00:00", ), "auto_off_set": AttributeDescription( name="Auto Shutdown", icon="mdi:progress-clock", default_enabled=False, - default_value="00:00:00", ), } -SENSORS = {**POWER_SENSORS, **TIME_SENSORS} +POWER_PLUG_SENSORS = POWER_SENSORS +WATER_HEATER_SENSORS = {**POWER_SENSORS, **TIME_SENSORS} -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: dict, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType, ) -> None: """Set up Switcher sensor from config entry.""" - device_data = hass.data[DOMAIN][DATA_DEVICE] - async_add_entities( - SwitcherSensorEntity(device_data, attribute, SENSORS[attribute]) - for attribute in SENSORS + @callback + def async_add_sensors(wrapper: SwitcherDeviceWrapper) -> None: + """Add sensors from Switcher device.""" + if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities( + SwitcherSensorEntity(wrapper, attribute, info) + for attribute, info in POWER_PLUG_SENSORS.items() + ) + elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities( + SwitcherSensorEntity(wrapper, attribute, info) + for attribute, info in WATER_HEATER_SENSORS.items() + ) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_sensors) ) -class SwitcherSensorEntity(SensorEntity): +class SwitcherSensorEntity(CoordinatorEntity, SensorEntity): """Representation of a Switcher sensor entity.""" def __init__( self, - device_data: SwitcherV2Device, + wrapper: SwitcherDeviceWrapper, attribute: str, description: AttributeDescription, ) -> None: """Initialize the entity.""" - self._device_data = device_data + super().__init__(wrapper) + self.wrapper = wrapper self.attribute = attribute - self.description = description # Entity class attributes - self._attr_name = f"{self._device_data.name} {self.description.name}" - self._attr_icon = self.description.icon - self._attr_unit_of_measurement = self.description.unit - self._attr_device_class = self.description.device_class - self._attr_entity_registry_enabled_default = self.description.default_enabled - self._attr_should_poll = False + self._attr_name = f"{wrapper.name} {description.name}" + self._attr_icon = description.icon + self._attr_unit_of_measurement = description.unit + self._attr_device_class = description.device_class + self._attr_entity_registry_enabled_default = description.default_enabled - self._attr_unique_id = f"{self._device_data.device_id}-{self._device_data.mac_addr}-{self.attribute}" + self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}-{attribute}" + self._attr_device_info = { + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + } + } @property def state(self) -> StateType: """Return value of sensor.""" - value = getattr(self._device_data, self.attribute) - if value and value is not WAITING_TEXT: - return value - - return self.description.default_value - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data - ) - ) - - @callback - def async_update_data(self, device_data: SwitcherV2Device) -> None: - """Update the entity data.""" - self._device_data = device_data - self.async_write_ha_state() + return getattr(self.wrapper.data, self.attribute) # type: ignore[no-any-return] diff --git a/homeassistant/components/switcher_kis/services.yaml b/homeassistant/components/switcher_kis/services.yaml index b4b2728fc2e..752b7f3de4c 100644 --- a/homeassistant/components/switcher_kis/services.yaml +++ b/homeassistant/components/switcher_kis/services.yaml @@ -5,6 +5,7 @@ set_auto_off: entity: integration: switcher_kis domain: switch + device_class: switch fields: auto_off: name: Auto off @@ -21,6 +22,7 @@ turn_on_with_timer: entity: integration: switcher_kis domain: switch + device_class: switch fields: timer_minutes: name: Timer diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json new file mode 100644 index 00000000000..9f3518bcf8d --- /dev/null +++ b/homeassistant/components/switcher_kis/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 21ebcf54cc7..c36fd0c208e 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -1,33 +1,42 @@ -"""Home Assistant Switcher Component Switch platform.""" +"""Switcher integration Switch platform.""" from __future__ import annotations -from aioswitcher.api import SwitcherV2Api -from aioswitcher.api.messages import SwitcherV2ControlResponseMSG -from aioswitcher.consts import ( - COMMAND_OFF, - COMMAND_ON, - STATE_OFF as SWITCHER_STATE_OFF, - STATE_ON as SWITCHER_STATE_ON, -) -from aioswitcher.devices import SwitcherV2Device +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from aioswitcher.api import Command, SwitcherApi, SwitcherBaseResponse +from aioswitcher.device import DeviceCategory, DeviceState import voluptuous as vol -from homeassistant.components.switch import SwitchEntity -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.components.switch import ( + DEVICE_CLASS_OUTLET, + DEVICE_CLASS_SWITCH, + SwitchEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import ( + config_validation as cv, + device_registry, + entity_platform, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import SwitcherDeviceWrapper from .const import ( CONF_AUTO_OFF, CONF_TIMER_MINUTES, - DATA_DEVICE, - DOMAIN, SERVICE_SET_AUTO_OFF_NAME, SERVICE_TURN_ON_WITH_TIMER_NAME, - SIGNAL_SWITCHER_DEVICE_UPDATE, + SIGNAL_DEVICE_ADD, ) +_LOGGER = logging.getLogger(__name__) + SERVICE_SET_AUTO_OFF_SCHEMA = { vol.Required(CONF_AUTO_OFF): cv.time_period_str, } @@ -39,135 +48,142 @@ SERVICE_TURN_ON_WITH_TIMER_SCHEMA = { } -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: dict, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: dict, ) -> None: - """Set up the switcher platform for the switch component.""" - if discovery_info is None: - return - - async def async_set_auto_off_service(entity, service_call: ServiceCall) -> None: - """Use for handling setting device auto-off service calls.""" - async with SwitcherV2Api( - hass.loop, - device_data.ip_addr, - device_data.phone_id, - device_data.device_id, - device_data.device_password, - ) as swapi: - await swapi.set_auto_shutdown(service_call.data[CONF_AUTO_OFF]) - - async def async_turn_on_with_timer_service( - entity, service_call: ServiceCall - ) -> None: - """Use for handling turning device on with a timer service calls.""" - async with SwitcherV2Api( - hass.loop, - device_data.ip_addr, - device_data.phone_id, - device_data.device_id, - device_data.device_password, - ) as swapi: - await swapi.control_device( - COMMAND_ON, service_call.data[CONF_TIMER_MINUTES] - ) - - device_data = hass.data[DOMAIN][DATA_DEVICE] - async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])]) - + """Set up Switcher switch from config entry.""" platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_AUTO_OFF_NAME, SERVICE_SET_AUTO_OFF_SCHEMA, - async_set_auto_off_service, + "async_set_auto_off_service", ) platform.async_register_entity_service( SERVICE_TURN_ON_WITH_TIMER_NAME, SERVICE_TURN_ON_WITH_TIMER_SCHEMA, - async_turn_on_with_timer_service, + "async_turn_on_with_timer_service", + ) + + @callback + def async_add_switch(wrapper: SwitcherDeviceWrapper) -> None: + """Add switch from Switcher device.""" + if wrapper.data.device_type.category == DeviceCategory.POWER_PLUG: + async_add_entities([SwitcherPowerPlugSwitchEntity(wrapper)]) + elif wrapper.data.device_type.category == DeviceCategory.WATER_HEATER: + async_add_entities([SwitcherWaterHeaterSwitchEntity(wrapper)]) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADD, async_add_switch) ) -class SwitcherControl(SwitchEntity): - """Home Assistant switch entity.""" +class SwitcherBaseSwitchEntity(CoordinatorEntity, SwitchEntity): + """Representation of a Switcher switch entity.""" - def __init__(self, device_data: SwitcherV2Device) -> None: + def __init__(self, wrapper: SwitcherDeviceWrapper) -> None: """Initialize the entity.""" - self._self_initiated = False - self._device_data = device_data - self._state = device_data.state + super().__init__(wrapper) + self.wrapper = wrapper + self.control_result: bool | None = None - @property - def name(self) -> str: - """Return the device's name.""" - return self._device_data.name + # Entity class attributes + self._attr_name = wrapper.name + self._attr_unique_id = f"{wrapper.device_id}-{wrapper.mac_address}" + self._attr_device_info = { + "connections": { + (device_registry.CONNECTION_NETWORK_MAC, wrapper.mac_address) + } + } - @property - def should_poll(self) -> bool: - """Return False, entity pushes its state to HA.""" - return False + @callback + def _handle_coordinator_update(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self.async_write_ha_state() - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device_data.device_id}-{self._device_data.mac_addr}" + 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 + error = None + + try: + async with SwitcherApi( + self.wrapper.data.ip_address, self.wrapper.device_id + ) as swapi: + response = await getattr(swapi, api)(*args) + except (asyncio.TimeoutError, OSError, RuntimeError) as err: + error = repr(err) + + if error or not response or not response.successful: + _LOGGER.error( + "Call api for %s failed, api: '%s', args: %s, response/error: %s", + self.name, + api, + args, + response or error, + ) + self.wrapper.last_update_success = False @property def is_on(self) -> bool: """Return True if entity is on.""" - return self._state == SWITCHER_STATE_ON + if self.control_result is not None: + return self.control_result - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._state in [SWITCHER_STATE_ON, SWITCHER_STATE_OFF] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data - ) - ) - - @callback - def async_update_data(self, device_data: SwitcherV2Device) -> None: - """Update the entity data.""" - if self._self_initiated: - self._self_initiated = False - else: - self._device_data = device_data - self._state = self._device_data.state - self.async_write_ha_state() + return bool(self.wrapper.data.device_state == DeviceState.ON) async def async_turn_on(self, **kwargs: dict) -> None: """Turn the entity on.""" - await self._control_device(True) + await self._async_call_api("control_device", Command.ON) + self.control_result = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: dict) -> None: """Turn the entity off.""" - await self._control_device(False) + await self._async_call_api("control_device", Command.OFF) + self.control_result = False + self.async_write_ha_state() - async def _control_device(self, send_on: bool) -> None: - """Turn the entity on or off.""" - response: SwitcherV2ControlResponseMSG = None - async with SwitcherV2Api( - self.hass.loop, - self._device_data.ip_addr, - self._device_data.phone_id, - self._device_data.device_id, - self._device_data.device_password, - ) as swapi: - response = await swapi.control_device( - COMMAND_ON if send_on else COMMAND_OFF - ) + async def async_set_auto_off_service(self, auto_off: timedelta) -> None: + """Use for handling setting device auto-off service calls.""" + _LOGGER.warning( + "Service '%s' is not supported by %s", + SERVICE_SET_AUTO_OFF_NAME, + self.name, + ) - if response and response.successful: - self._self_initiated = True - self._state = SWITCHER_STATE_ON if send_on else SWITCHER_STATE_OFF - self.async_write_ha_state() + async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: + """Use for turning device on with a timer service calls.""" + _LOGGER.warning( + "Service '%s' is not supported by %s", + SERVICE_TURN_ON_WITH_TIMER_NAME, + self.name, + ) + + +class SwitcherPowerPlugSwitchEntity(SwitcherBaseSwitchEntity): + """Representation of a Switcher power plug switch entity.""" + + _attr_device_class = DEVICE_CLASS_OUTLET + + +class SwitcherWaterHeaterSwitchEntity(SwitcherBaseSwitchEntity): + """Representation of a Switcher water heater switch entity.""" + + _attr_device_class = DEVICE_CLASS_SWITCH + + async def async_set_auto_off_service(self, auto_off: timedelta) -> None: + """Use for handling setting device auto-off service calls.""" + await self._async_call_api("set_auto_shutdown", auto_off) + self.async_write_ha_state() + + async def async_turn_on_with_timer_service(self, timer_minutes: int) -> None: + """Use for turning device on with a timer service calls.""" + await self._async_call_api("control_device", Command.ON, timer_minutes) + self.control_result = True + self.async_write_ha_state() diff --git a/homeassistant/components/switcher_kis/translations/ca.json b/homeassistant/components/switcher_kis/translations/ca.json new file mode 100644 index 00000000000..dc21c371e60 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/ca.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius a la xarxa", + "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." + }, + "step": { + "confirm": { + "description": "Vols comen\u00e7ar la configuraci\u00f3?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/de.json b/homeassistant/components/switcher_kis/translations/de.json new file mode 100644 index 00000000000..19cd4b8c70e --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/de.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", + "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." + }, + "step": { + "confirm": { + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/en.json b/homeassistant/components/switcher_kis/translations/en.json new file mode 100644 index 00000000000..f05becffed3 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to start set up?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/et.json b/homeassistant/components/switcher_kis/translations/et.json new file mode 100644 index 00000000000..9e7bb472e0d --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/et.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "V\u00f5rgust ei leitud \u00fchtegi seadet", + "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." + }, + "step": { + "confirm": { + "description": "Kas soovid alustada seadistamist?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/fr.json b/homeassistant/components/switcher_kis/translations/fr.json new file mode 100644 index 00000000000..e6e7a3c271f --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/fr.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau", + "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." + }, + "step": { + "confirm": { + "description": "Voulez-vous commencer l'installation ?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/he.json b/homeassistant/components/switcher_kis/translations/he.json new file mode 100644 index 00000000000..d3d68dccc93 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/he.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "step": { + "confirm": { + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/it.json b/homeassistant/components/switcher_kis/translations/it.json new file mode 100644 index 00000000000..0278fe07bfe --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/it.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo trovato sulla rete", + "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." + }, + "step": { + "confirm": { + "description": "Vuoi iniziare la configurazione?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/nl.json b/homeassistant/components/switcher_kis/translations/nl.json new file mode 100644 index 00000000000..d11896014fd --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/nl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen apparaten gevonden op het netwerk", + "single_instance_allowed": "Al geconfigureerd. Slechts een enkele configuratie mogelijk." + }, + "step": { + "confirm": { + "description": "Wil je beginnen met instellen?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/pl.json b/homeassistant/components/switcher_kis/translations/pl.json new file mode 100644 index 00000000000..a8ee3fa57ac --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/pl.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci", + "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." + }, + "step": { + "confirm": { + "description": "Czy chcesz rozpocz\u0105\u0107 konfiguracj\u0119?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/ru.json b/homeassistant/components/switcher_kis/translations/ru.json new file mode 100644 index 00000000000..85a42bf1be5 --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/ru.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." + }, + "step": { + "confirm": { + "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0447\u0430\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/translations/zh-Hant.json b/homeassistant/components/switcher_kis/translations/zh-Hant.json new file mode 100644 index 00000000000..90c98e491df --- /dev/null +++ b/homeassistant/components/switcher_kis/translations/zh-Hant.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u88dd\u7f6e", + "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u958b\u59cb\u8a2d\u5b9a\uff1f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switcher_kis/utils.py b/homeassistant/components/switcher_kis/utils.py new file mode 100644 index 00000000000..b2cc45cf67c --- /dev/null +++ b/homeassistant/components/switcher_kis/utils.py @@ -0,0 +1,54 @@ +"""Switcher integration helpers functions.""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any, Callable + +from aioswitcher.bridge import SwitcherBase, SwitcherBridge + +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_BRIDGE, DISCOVERY_TIME_SEC, DOMAIN + +_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]: + """Discover Switcher devices.""" + _LOGGER.debug("Starting discovery") + discovered_devices = {} + + @callback + def on_device_data_callback(device: SwitcherBase) -> None: + """Use as a callback for device data.""" + if device.device_id in discovered_devices: + return + + discovered_devices[device.device_id] = device + + bridge = SwitcherBridge(on_device_data_callback) + await bridge.start() + await asyncio.sleep(DISCOVERY_TIME_SEC) + await bridge.stop() + + _LOGGER.debug("Finished discovery, discovered devices: %s", len(discovered_devices)) + return discovered_devices diff --git a/homeassistant/components/syncthing/strings.json b/homeassistant/components/syncthing/strings.json index 1781df56f1e..36d1a688a70 100644 --- a/homeassistant/components/syncthing/strings.json +++ b/homeassistant/components/syncthing/strings.json @@ -1,5 +1,4 @@ { - "title": "Syncthing", "config": { "step": { "user": { diff --git a/homeassistant/components/syncthing/translations/fr.json b/homeassistant/components/syncthing/translations/fr.json new file mode 100644 index 00000000000..12486fb5cf2 --- /dev/null +++ b/homeassistant/components/syncthing/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Le service est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "user": { + "data": { + "title": "Configurer l'int\u00e9gration de Syncthing", + "token": "Jeton", + "url": "URL", + "verify_ssl": "V\u00e9rifier le certificat SSL" + } + } + } + }, + "title": "Synchroniser" +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/translations/hu.json b/homeassistant/components/syncthing/translations/hu.json new file mode 100644 index 00000000000..59ed4021b3f --- /dev/null +++ b/homeassistant/components/syncthing/translations/hu.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "A szolg\u00e1ltat\u00e1s m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + }, + "step": { + "user": { + "data": { + "title": "A szinkroniz\u00e1l\u00e1s integr\u00e1ci\u00f3j\u00e1nak be\u00e1ll\u00edt\u00e1sa", + "token": "Token", + "url": "URL", + "verify_ssl": "Ellen\u0151rizze az SSL tan\u00fas\u00edtv\u00e1nyt" + } + } + } + }, + "title": "Szinkroniz\u00e1l\u00e1s" +} \ No newline at end of file diff --git a/homeassistant/components/syncthing/translations/id.json b/homeassistant/components/syncthing/translations/id.json new file mode 100644 index 00000000000..2aa4f701c95 --- /dev/null +++ b/homeassistant/components/syncthing/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Layanan sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid" + }, + "step": { + "user": { + "data": { + "token": "Token", + "url": "URL", + "verify_ssl": "Verifikasi sertifikat SSL" + } + } + } + }, + "title": "Syncthing" +} \ No newline at end of file diff --git a/homeassistant/components/syncthru/translations/cs.json b/homeassistant/components/syncthru/translations/cs.json index d34668146a3..2c9fbda88f0 100644 --- a/homeassistant/components/syncthru/translations/cs.json +++ b/homeassistant/components/syncthru/translations/cs.json @@ -6,7 +6,7 @@ "error": { "invalid_url": "Neplatn\u00e1 URL adresa" }, - "flow_title": "Tisk\u00e1rna Samsung SyncThru: {name}", + "flow_title": "{name}", "step": { "confirm": { "data": { diff --git a/homeassistant/components/syncthru/translations/de.json b/homeassistant/components/syncthru/translations/de.json index f7533630216..699b88286dc 100644 --- a/homeassistant/components/syncthru/translations/de.json +++ b/homeassistant/components/syncthru/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist schon konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "invalid_url": "Ung\u00fcltige URL", diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 84b392eb3fe..a9ca7b4c48d 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -18,15 +18,22 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, SynologyDSMLoginFailedException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, SynologyDSMRequestException, ) -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - CONF_DISKS, + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_HOST, CONF_MAC, CONF_PASSWORD, @@ -46,7 +53,6 @@ from homeassistant.helpers.device_registry import ( async_get_registry as get_dev_reg, ) from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -56,19 +62,15 @@ from homeassistant.helpers.update_coordinator import ( from .const import ( CONF_DEVICE_TOKEN, CONF_SERIAL, - CONF_VOLUMES, COORDINATOR_CAMERAS, COORDINATOR_CENTRAL, COORDINATOR_SWITCHES, DEFAULT_SCAN_INTERVAL, - DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, - ENTITY_CLASS, ENTITY_ENABLE, - ENTITY_ICON, - ENTITY_NAME, - ENTITY_UNIT, + EXCEPTION_DETAILS, + EXCEPTION_UNKNOWN, PLATFORMS, SERVICE_REBOOT, SERVICE_SHUTDOWN, @@ -83,26 +85,8 @@ from .const import ( EntityInfo, ) -CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_USE_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DISKS): cv.ensure_list, - vol.Optional(CONF_VOLUMES): cv.ensure_list, - } -) +CONFIG_SCHEMA = cv.deprecated(DOMAIN) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONFIG_SCHEMA]))}, - ), - extra=vol.ALLOW_EXTRA, -) ATTRIBUTION = "Data provided by Synology" @@ -110,25 +94,6 @@ ATTRIBUTION = "Data provided by Synology" _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Synology DSM sensors from legacy config file.""" - - conf = config.get(DOMAIN) - if conf is None: - return True - - for dsm_conf in conf: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=dsm_conf, - ) - ) - - return True - - async def async_setup_entry( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry ) -> bool: @@ -167,7 +132,7 @@ async def async_setup_entry( # noqa: C901 for entity_key, entity_attrs in entries.items(): if ( device_id - and entity_attrs[ENTITY_NAME] == "Status" + and entity_attrs[ATTR_NAME] == "Status" and "Status" in entity_entry.unique_id and "(Smart)" not in entity_entry.unique_id ): @@ -178,7 +143,7 @@ async def async_setup_entry( # noqa: C901 entity_type = entity_key continue - if entity_attrs[ENTITY_NAME] == label: + if entity_attrs[ATTR_NAME] == label: entity_type = entity_key if entity_type is None: @@ -223,6 +188,33 @@ async def async_setup_entry( # noqa: C901 api = SynoApi(hass, entry) try: await api.async_setup() + except ( + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, + ) as err: + if err.args[0] and isinstance(err.args[0], dict): + # pylint: disable=no-member + details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) + else: + details = EXCEPTION_UNKNOWN + _LOGGER.debug( + "Reauthentication for DSM '%s' needed - reason: %s", + entry.unique_id, + details, + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": {**entry.data}, + EXCEPTION_DETAILS: details, + }, + ) + ) + return False except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: _LOGGER.debug( "Unable to connect to DSM '%s' during setup: %s", entry.unique_id, err @@ -625,12 +617,13 @@ class SynologyDSMBaseEntity(CoordinatorEntity): self._api = api self._api_key = entity_type.split(":")[0] self.entity_type = entity_type.split(":")[-1] - self._name = f"{api.network.hostname} {entity_info[ENTITY_NAME]}" - self._class = entity_info[ENTITY_CLASS] + self._name = f"{api.network.hostname} {entity_info[ATTR_NAME]}" + self._class = entity_info[ATTR_DEVICE_CLASS] self._enable_default = entity_info[ENTITY_ENABLE] - self._icon = entity_info[ENTITY_ICON] - self._unit = entity_info[ENTITY_UNIT] + self._icon = entity_info[ATTR_ICON] + self._unit = entity_info[ATTR_UNIT_OF_MEASUREMENT] self._unique_id = f"{self._api.information.serial}_{entity_type}" + self._attr_state_class = entity_info[ATTR_STATE_CLASS] @property def unique_id(self) -> str: @@ -719,7 +712,9 @@ class SynologyDSMDeviceEntity(SynologyDSMBaseEntity): self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] - self._name = f"{self._api.network.hostname} {self._device_name} {entity_info[ENTITY_NAME]}" + self._name = ( + f"{self._api.network.hostname} {self._device_name} {entity_info[ATTR_NAME]}" + ) self._unique_id += f"_{self._device_id}" @property diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index e94dc1a94ac..5f27aa3b038 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -32,17 +32,13 @@ async def async_setup_entry( | SynoDSMUpgradeBinarySensor | SynoDSMStorageBinarySensor ] = [ - SynoDSMSecurityBinarySensor( - api, sensor_type, SECURITY_BINARY_SENSORS[sensor_type], coordinator - ) - for sensor_type in SECURITY_BINARY_SENSORS + SynoDSMSecurityBinarySensor(api, sensor_type, sensor, coordinator) + for sensor_type, sensor in SECURITY_BINARY_SENSORS.items() ] entities += [ - SynoDSMUpgradeBinarySensor( - api, sensor_type, UPGRADE_BINARY_SENSORS[sensor_type], coordinator - ) - for sensor_type in UPGRADE_BINARY_SENSORS + SynoDSMUpgradeBinarySensor(api, sensor_type, sensor, coordinator) + for sensor_type, sensor in UPGRADE_BINARY_SENSORS.items() ] # Handle all disks @@ -52,11 +48,11 @@ async def async_setup_entry( SynoDSMStorageBinarySensor( api, sensor_type, - STORAGE_DISK_BINARY_SENSORS[sensor_type], + sensor, coordinator, disk, ) - for sensor_type in STORAGE_DISK_BINARY_SENSORS + for sensor_type, sensor in STORAGE_DISK_BINARY_SENSORS.items() ] async_add_entities(entities) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index a969107bf83..8341b8b121a 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -10,23 +10,21 @@ from synology_dsm.exceptions import ( ) from homeassistant.components.camera import SUPPORT_STREAM, Camera +from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi, SynologyDSMBaseEntity -from .const import ( - COORDINATOR_CAMERAS, - DOMAIN, - ENTITY_CLASS, - ENTITY_ENABLE, - ENTITY_ICON, - ENTITY_NAME, - ENTITY_UNIT, - SYNO_API, -) +from .const import COORDINATOR_CAMERAS, DOMAIN, ENTITY_ENABLE, SYNO_API _LOGGER = logging.getLogger(__name__) @@ -70,11 +68,12 @@ class SynoDSMCamera(SynologyDSMBaseEntity, Camera): api, f"{SynoSurveillanceStation.CAMERA_API_KEY}:{camera_id}", { - ENTITY_NAME: coordinator.data["cameras"][camera_id].name, + ATTR_NAME: coordinator.data["cameras"][camera_id].name, ENTITY_ENABLE: coordinator.data["cameras"][camera_id].is_enabled, - ENTITY_CLASS: None, - ENTITY_ICON: None, - ENTITY_UNIT: None, + ATTR_DEVICE_CLASS: None, + ATTR_ICON: None, + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_STATE_CLASS: None, }, coordinator, ) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 1a3681daf32..97f9e4343fa 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -46,6 +46,7 @@ from .const import ( DEFAULT_USE_SSL, DEFAULT_VERIFY_SSL, DOMAIN, + EXCEPTION_DETAILS, ) _LOGGER = logging.getLogger(__name__) @@ -57,6 +58,15 @@ def _discovery_schema_with_defaults(discovery_info: DiscoveryInfoType) -> vol.Sc return vol.Schema(_ordered_shared_schema(discovery_info)) +def _reauth_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: + return vol.Schema( + { + vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")): str, + vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")): str, + } + ) + + def _user_schema_with_defaults(user_input: dict[str, Any]) -> vol.Schema: user_schema = { vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "")): str, @@ -100,6 +110,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the synology_dsm config flow.""" self.saved_user_input: dict[str, Any] = {} self.discovered_conf: dict[str, Any] = {} + self.reauth_conf: dict[str, Any] = {} + self.reauth_reason: str | None = None async def _show_setup_form( self, @@ -110,10 +122,18 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if not user_input: user_input = {} + description_placeholders = {} + if self.discovered_conf: user_input.update(self.discovered_conf) step_id = "link" data_schema = _discovery_schema_with_defaults(user_input) + description_placeholders = self.discovered_conf + elif self.reauth_conf: + user_input.update(self.reauth_conf) + step_id = "reauth" + data_schema = _reauth_schema_with_defaults(user_input) + description_placeholders = {EXCEPTION_DETAILS: self.reauth_reason} else: step_id = "user" data_schema = _user_schema_with_defaults(user_input) @@ -122,7 +142,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): step_id=step_id, data_schema=data_schema, errors=errors or {}, - description_placeholders=self.discovered_conf or {}, + description_placeholders=description_placeholders, ) async def async_step_user( @@ -137,6 +157,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if self.discovered_conf: user_input.update(self.discovered_conf) + if self.reauth_conf: + self.reauth_conf.update( + { + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + } + ) + user_input.update(self.reauth_conf) + host = user_input[CONF_HOST] port = user_input.get(CONF_PORT) username = user_input[CONF_USERNAME] @@ -181,10 +210,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_setup_form(user_input, errors) # unique_id should be serial for services purpose - await self.async_set_unique_id(serial, raise_on_progress=False) - - # Check if already configured - self._abort_if_unique_id_configured() + existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) config_data = { CONF_HOST: host, @@ -202,6 +228,15 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input.get(CONF_VOLUMES): config_data[CONF_VOLUMES] = user_input[CONF_VOLUMES] + if existing_entry and self.reauth_conf: + self.hass.config_entries.async_update_entry( + existing_entry, data=config_data + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + if existing_entry: + return self.async_abort(reason="already_configured") + return self.async_create_entry(title=host, data=config_data) async def async_step_ssdp(self, discovery_info: DiscoveryInfoType) -> FlowResult: @@ -227,10 +262,14 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_import( + async def async_step_reauth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Import a config entry.""" + """Perform reauth upon an API authentication error.""" + self.reauth_conf = self.context.get("data", {}) + self.reauth_reason = self.context.get(EXCEPTION_DETAILS) + if user_input is None: + return await self.async_step_user() return await self.async_step_user(user_input) async def async_step_link(self, user_input: dict[str, Any]) -> FlowResult: diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index 334832ddf2b..fdbbb5678c2 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -11,7 +11,12 @@ from synology_dsm.api.storage.storage import SynoStorage from synology_dsm.api.surveillance_station import SynoSurveillanceStation from homeassistant.components.binary_sensor import DEVICE_CLASS_SAFETY +from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ICON, + ATTR_NAME, + ATTR_UNIT_OF_MEASUREMENT, DATA_MEGABYTES, DATA_RATE_KILOBYTES_PER_SECOND, DATA_TERABYTES, @@ -25,9 +30,10 @@ class EntityInfo(TypedDict): """TypedDict for EntityInfo.""" name: str - unit: str | None + unit_of_measurement: str | None icon: str | None device_class: str | None + state_class: str | None enable: bool @@ -37,6 +43,8 @@ COORDINATOR_CAMERAS = "coordinator_cameras" COORDINATOR_CENTRAL = "coordinator_central" COORDINATOR_SWITCHES = "coordinator_switches" SYSTEM_LOADED = "system_loaded" +EXCEPTION_DETAILS = "details" +EXCEPTION_UNKNOWN = "unknown" # Entry keys SYNO_API = "syno_api" @@ -56,11 +64,6 @@ DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = 10 # sec ENTITY_UNIT_LOAD = "load" - -ENTITY_NAME: Final = "name" -ENTITY_UNIT: Final = "unit" -ENTITY_ICON: Final = "icon" -ENTITY_CLASS: Final = "device_class" ENTITY_ENABLE: Final = "enable" # Services @@ -76,249 +79,281 @@ SERVICES = [ # Binary sensors UPGRADE_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUpgrade.API_KEY}:update_available": { - ENTITY_NAME: "Update available", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:update", - ENTITY_CLASS: None, + ATTR_NAME: "Update available", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: "mdi:update", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, } SECURITY_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreSecurity.API_KEY}:status": { - ENTITY_NAME: "Security status", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_SAFETY, + ATTR_NAME: "Security status", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, } STORAGE_DISK_BINARY_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:disk_exceed_bad_sector_thr": { - ENTITY_NAME: "Exceeded Max Bad Sectors", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_SAFETY, + ATTR_NAME: "Exceeded Max Bad Sectors", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:disk_below_remain_life_thr": { - ENTITY_NAME: "Below Min Remaining Life", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_SAFETY, + ATTR_NAME: "Below Min Remaining Life", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_SAFETY, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, } # Sensors UTILISATION_SENSORS: dict[str, EntityInfo] = { f"{SynoCoreUtilization.API_KEY}:cpu_other_load": { - ENTITY_NAME: "CPU Utilization (Other)", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Utilization (Other)", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:cpu_user_load": { - ENTITY_NAME: "CPU Utilization (User)", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Utilization (User)", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:cpu_system_load": { - ENTITY_NAME: "CPU Utilization (System)", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Utilization (System)", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:cpu_total_load": { - ENTITY_NAME: "CPU Utilization (Total)", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Utilization (Total)", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:cpu_1min_load": { - ENTITY_NAME: "CPU Load Average (1 min)", - ENTITY_UNIT: ENTITY_UNIT_LOAD, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Load Average (1 min)", + ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: None, }, f"{SynoCoreUtilization.API_KEY}:cpu_5min_load": { - ENTITY_NAME: "CPU Load Average (5 min)", - ENTITY_UNIT: ENTITY_UNIT_LOAD, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Load Average (5 min)", + ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoCoreUtilization.API_KEY}:cpu_15min_load": { - ENTITY_NAME: "CPU Load Average (15 min)", - ENTITY_UNIT: ENTITY_UNIT_LOAD, - ENTITY_ICON: "mdi:chip", - ENTITY_CLASS: None, + ATTR_NAME: "CPU Load Average (15 min)", + ATTR_UNIT_OF_MEASUREMENT: ENTITY_UNIT_LOAD, + ATTR_ICON: "mdi:chip", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoCoreUtilization.API_KEY}:memory_real_usage": { - ENTITY_NAME: "Memory Usage (Real)", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Usage (Real)", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_size": { - ENTITY_NAME: "Memory Size", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Size", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_cached": { - ENTITY_NAME: "Memory Cached", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Cached", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_available_swap": { - ENTITY_NAME: "Memory Available (Swap)", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Available (Swap)", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_available_real": { - ENTITY_NAME: "Memory Available (Real)", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Available (Real)", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_total_swap": { - ENTITY_NAME: "Memory Total (Swap)", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Total (Swap)", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:memory_total_real": { - ENTITY_NAME: "Memory Total (Real)", - ENTITY_UNIT: DATA_MEGABYTES, - ENTITY_ICON: "mdi:memory", - ENTITY_CLASS: None, + ATTR_NAME: "Memory Total (Real)", + ATTR_UNIT_OF_MEASUREMENT: DATA_MEGABYTES, + ATTR_ICON: "mdi:memory", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_up": { - ENTITY_NAME: "Network Up", - ENTITY_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - ENTITY_ICON: "mdi:upload", - ENTITY_CLASS: None, + ATTR_NAME: "Network Up", + ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, + ATTR_ICON: "mdi:upload", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoCoreUtilization.API_KEY}:network_down": { - ENTITY_NAME: "Network Down", - ENTITY_UNIT: DATA_RATE_KILOBYTES_PER_SECOND, - ENTITY_ICON: "mdi:download", - ENTITY_CLASS: None, + ATTR_NAME: "Network Down", + ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_KILOBYTES_PER_SECOND, + ATTR_ICON: "mdi:download", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, } STORAGE_VOL_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:volume_status": { - ENTITY_NAME: "Status", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:checkbox-marked-circle-outline", - ENTITY_CLASS: None, + ATTR_NAME: "Status", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: "mdi:checkbox-marked-circle-outline", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:volume_size_total": { - ENTITY_NAME: "Total Size", - ENTITY_UNIT: DATA_TERABYTES, - ENTITY_ICON: "mdi:chart-pie", - ENTITY_CLASS: None, + ATTR_NAME: "Total Size", + ATTR_UNIT_OF_MEASUREMENT: DATA_TERABYTES, + ATTR_ICON: "mdi:chart-pie", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoStorage.API_KEY}:volume_size_used": { - ENTITY_NAME: "Used Space", - ENTITY_UNIT: DATA_TERABYTES, - ENTITY_ICON: "mdi:chart-pie", - ENTITY_CLASS: None, + ATTR_NAME: "Used Space", + ATTR_UNIT_OF_MEASUREMENT: DATA_TERABYTES, + ATTR_ICON: "mdi:chart-pie", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoStorage.API_KEY}:volume_percentage_used": { - ENTITY_NAME: "Volume Used", - ENTITY_UNIT: PERCENTAGE, - ENTITY_ICON: "mdi:chart-pie", - ENTITY_CLASS: None, + ATTR_NAME: "Volume Used", + ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, + ATTR_ICON: "mdi:chart-pie", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:volume_disk_temp_avg": { - ENTITY_NAME: "Average Disk Temp", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_NAME: "Average Disk Temp", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:volume_disk_temp_max": { - ENTITY_NAME: "Maximum Disk Temp", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_NAME: "Maximum Disk Temp", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: None, }, } STORAGE_DISK_SENSORS: dict[str, EntityInfo] = { f"{SynoStorage.API_KEY}:disk_smart_status": { - ENTITY_NAME: "Status (Smart)", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:checkbox-marked-circle-outline", - ENTITY_CLASS: None, + ATTR_NAME: "Status (Smart)", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: "mdi:checkbox-marked-circle-outline", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:disk_status": { - ENTITY_NAME: "Status", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:checkbox-marked-circle-outline", - ENTITY_CLASS: None, + ATTR_NAME: "Status", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: "mdi:checkbox-marked-circle-outline", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, f"{SynoStorage.API_KEY}:disk_temp": { - ENTITY_NAME: "Temperature", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_NAME: "Temperature", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, } INFORMATION_SENSORS: dict[str, EntityInfo] = { f"{SynoDSMInformation.API_KEY}:temperature": { - ENTITY_NAME: "temperature", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_NAME: "temperature", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: STATE_CLASS_MEASUREMENT, }, f"{SynoDSMInformation.API_KEY}:uptime": { - ENTITY_NAME: "last boot", - ENTITY_UNIT: None, - ENTITY_ICON: None, - ENTITY_CLASS: DEVICE_CLASS_TIMESTAMP, + ATTR_NAME: "last boot", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: None, + ATTR_DEVICE_CLASS: DEVICE_CLASS_TIMESTAMP, ENTITY_ENABLE: False, + ATTR_STATE_CLASS: None, }, } # Switch SURVEILLANCE_SWITCH: dict[str, EntityInfo] = { f"{SynoSurveillanceStation.HOME_MODE_API_KEY}:home_mode": { - ENTITY_NAME: "home mode", - ENTITY_UNIT: None, - ENTITY_ICON: "mdi:home-account", - ENTITY_CLASS: None, + ATTR_NAME: "home mode", + ATTR_UNIT_OF_MEASUREMENT: None, + ATTR_ICON: "mdi:home-account", + ATTR_DEVICE_CLASS: None, ENTITY_ENABLE: True, + ATTR_STATE_CLASS: None, }, } diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index afa8e2674de..04d7f43bb75 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -2,7 +2,7 @@ "domain": "synology_dsm", "name": "Synology DSM", "documentation": "https://www.home-assistant.io/integrations/synology_dsm", - "requirements": ["synologydsm-api==1.0.2"], + "requirements": ["py-synologydsm-api==1.0.3"], "codeowners": ["@hacf-fr", "@Quentame", "@mib1185"], "config_flow": true, "ssdp": [ diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 4cf982e15f6..5942ce4a5b1 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -46,10 +46,8 @@ async def async_setup_entry( coordinator = data[COORDINATOR_CENTRAL] entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ - SynoDSMUtilSensor( - api, sensor_type, UTILISATION_SENSORS[sensor_type], coordinator - ) - for sensor_type in UTILISATION_SENSORS + SynoDSMUtilSensor(api, sensor_type, sensor, coordinator) + for sensor_type, sensor in UTILISATION_SENSORS.items() ] # Handle all volumes @@ -59,11 +57,11 @@ async def async_setup_entry( SynoDSMStorageSensor( api, sensor_type, - STORAGE_VOL_SENSORS[sensor_type], + sensor, coordinator, volume, ) - for sensor_type in STORAGE_VOL_SENSORS + for sensor_type, sensor in STORAGE_VOL_SENSORS.items() ] # Handle all disks @@ -73,18 +71,16 @@ async def async_setup_entry( SynoDSMStorageSensor( api, sensor_type, - STORAGE_DISK_SENSORS[sensor_type], + sensor, coordinator, disk, ) - for sensor_type in STORAGE_DISK_SENSORS + for sensor_type, sensor in STORAGE_DISK_SENSORS.items() ] entities += [ - SynoDSMInfoSensor( - api, sensor_type, INFORMATION_SENSORS[sensor_type], coordinator - ) - for sensor_type in INFORMATION_SENSORS + SynoDSMInfoSensor(api, sensor_type, sensor, coordinator) + for sensor_type, sensor in INFORMATION_SENSORS.items() ] async_add_entities(entities) diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index 1464b8a6a06..6baaaaef9f6 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -29,6 +29,14 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } + }, + "reauth": { + "title": "Synology DSM [%key:common::config_flow::title::reauth%]", + "description": "Reason: {details}", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -39,7 +47,8 @@ "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%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 27ffbfde799..e08516ec03a 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -44,9 +44,9 @@ async def async_setup_entry( await coordinator.async_refresh() entities += [ SynoDSMSurveillanceHomeModeToggle( - api, sensor_type, SURVEILLANCE_SWITCH[sensor_type], version, coordinator + api, sensor_type, switch, version, coordinator ) - for sensor_type in SURVEILLANCE_SWITCH + for sensor_type, switch in SURVEILLANCE_SWITCH.items() ] async_add_entities(entities, True) diff --git a/homeassistant/components/synology_dsm/translations/ca.json b/homeassistant/components/synology_dsm/translations/ca.json index e08ed1d74ce..2ac5d16b286 100644 --- a/homeassistant/components/synology_dsm/translations/ca.json +++ b/homeassistant/components/synology_dsm/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -29,6 +30,14 @@ "description": "Vols configurar {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + }, + "description": "Motiu: {details}", + "title": "Reautenticaci\u00f3 de la integraci\u00f3 Synology DSM" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/synology_dsm/translations/cs.json b/homeassistant/components/synology_dsm/translations/cs.json index a9fdd199618..561c6c97e0b 100644 --- a/homeassistant/components/synology_dsm/translations/cs.json +++ b/homeassistant/components/synology_dsm/translations/cs.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno" + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "reauth_successful": "Op\u011btovn\u00e9 ov\u011b\u0159en\u00ed bylo \u00fasp\u011b\u0161n\u00e9" }, "error": { "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit", @@ -28,6 +29,13 @@ "description": "Chcete nastavit {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "Synology DSM Znovu ov\u011b\u0159it integraci" + }, "user": { "data": { "host": "Hostitel", diff --git a/homeassistant/components/synology_dsm/translations/de.json b/homeassistant/components/synology_dsm/translations/de.json index 932cc42db1d..86c154e8567 100644 --- a/homeassistant/components/synology_dsm/translations/de.json +++ b/homeassistant/components/synology_dsm/translations/de.json @@ -1,13 +1,14 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", "invalid_auth": "Ung\u00fcltige Authentifizierung", - "missing_data": "Fehlende Daten: Bitte versuchen Sie es sp\u00e4ter noch einmal oder eine andere Konfiguration", - "otp_failed": "Die zweistufige Authentifizierung ist fehlgeschlagen. Versuchen Sie es erneut mit einem neuen Code", + "missing_data": "Fehlende Daten: Bitte versuche es sp\u00e4ter noch einmal oder eine andere Konfiguration", + "otp_failed": "Die zweistufige Authentifizierung ist fehlgeschlagen. Versuche es erneut mit einem neuen Code", "unknown": "Unerwarteter Fehler" }, "flow_title": "{name} ({host})", @@ -24,11 +25,19 @@ "port": "Port", "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL Zertifikat verifizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, - "description": "M\u00f6chten Sie {name} ({host}) einrichten?", + "description": "M\u00f6chtest du {name} ({host}) einrichten?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Passwort", + "username": "Benutzername" + }, + "description": "Grund: {details}", + "title": "Synology DSM Integration erneut authentifizieren" + }, "user": { "data": { "host": "Host", @@ -36,7 +45,7 @@ "port": "Port", "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", - "verify_ssl": "SSL Zertifikat verifizieren" + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, "title": "Synology DSM" } diff --git a/homeassistant/components/synology_dsm/translations/en.json b/homeassistant/components/synology_dsm/translations/en.json index 397bad8b14e..0231f8ddb3c 100644 --- a/homeassistant/components/synology_dsm/translations/en.json +++ b/homeassistant/components/synology_dsm/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -29,6 +30,14 @@ "description": "Do you want to setup {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Reason: {details}", + "title": "Synology DSM Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/et.json b/homeassistant/components/synology_dsm/translations/et.json index 7d192828d67..eebfd25938b 100644 --- a/homeassistant/components/synology_dsm/translations/et.json +++ b/homeassistant/components/synology_dsm/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -29,6 +30,14 @@ "description": "Kas soovid seadistada {name}({host})?", "title": "" }, + "reauth": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + }, + "description": "P\u00f5hjus: {details}", + "title": "Synology DSM: Taastuvasta sidumine" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index 361bd8de76e..b254fc8e561 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9" + "already_configured": "H\u00f4te d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -29,6 +30,14 @@ "description": "Voulez-vous configurer {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "description": "Raison: {details}", + "title": "Synology DSM R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "host": "Nom d'h\u00f4te ou adresse IP", diff --git a/homeassistant/components/synology_dsm/translations/he.json b/homeassistant/components/synology_dsm/translations/he.json index a671684a770..4d95d5c2c3c 100644 --- a/homeassistant/components/synology_dsm/translations/he.json +++ b/homeassistant/components/synology_dsm/translations/he.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", @@ -19,7 +20,13 @@ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" }, - "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {model} ({host})?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea {name} ({host})?" + }, + "reauth": { + "data": { + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } }, "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/hu.json b/homeassistant/components/synology_dsm/translations/hu.json index e5af260449a..7ac507f1efa 100644 --- a/homeassistant/components/synology_dsm/translations/hu.json +++ b/homeassistant/components/synology_dsm/translations/hu.json @@ -37,5 +37,14 @@ "title": "Synology DSM" } } + }, + "options": { + "step": { + "init": { + "data": { + "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s (m\u00e1sodperc)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/it.json b/homeassistant/components/synology_dsm/translations/it.json index 6f5fd4ac245..bb6965255bb 100644 --- a/homeassistant/components/synology_dsm/translations/it.json +++ b/homeassistant/components/synology_dsm/translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -29,6 +30,14 @@ "description": "Vuoi impostare {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "description": "Motivo: {details}", + "title": "Synology DSM Autenticare nuovamente l'integrazione" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/nl.json b/homeassistant/components/synology_dsm/translations/nl.json index 6fa342800a2..6ce9f1b63b9 100644 --- a/homeassistant/components/synology_dsm/translations/nl.json +++ b/homeassistant/components/synology_dsm/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie was succesvol" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -29,6 +30,14 @@ "description": "Wil je {name} ({host}) instellen?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + }, + "description": "Reden: {details}", + "title": "Synology DSM Verifieer de integratie opnieuw" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/synology_dsm/translations/pl.json b/homeassistant/components/synology_dsm/translations/pl.json index e060f666f3c..2979aa2c416 100644 --- a/homeassistant/components/synology_dsm/translations/pl.json +++ b/homeassistant/components/synology_dsm/translations/pl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -29,6 +30,14 @@ "description": "Czy chcesz skonfigurowa\u0107 {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Pow\u00f3d: {details}", + "title": "Ponownie uwierzytelnij integracj\u0119 Synology DSM" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", diff --git a/homeassistant/components/synology_dsm/translations/ru.json b/homeassistant/components/synology_dsm/translations/ru.json index 9a7157b6dc3..4a2963dc5d5 100644 --- a/homeassistant/components/synology_dsm/translations/ru.json +++ b/homeassistant/components/synology_dsm/translations/ru.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.", @@ -29,6 +30,14 @@ "description": "\u0425\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c {name} ({host})?", "title": "Synology DSM" }, + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + }, + "description": "\u041f\u0440\u0438\u0447\u0438\u043d\u0430: {details}", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f Synology DSM" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/synology_dsm/translations/zh-Hant.json b/homeassistant/components/synology_dsm/translations/zh-Hant.json index 9b2a4726fc4..c4d466832e7 100644 --- a/homeassistant/components/synology_dsm/translations/zh-Hant.json +++ b/homeassistant/components/synology_dsm/translations/zh-Hant.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -29,6 +30,14 @@ "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} ({host})\uff1f", "title": "\u7fa4\u6689 DSM" }, + "reauth": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u8a73\u7d30\u8cc7\u8a0a\uff1a{details}", + "title": "Synology DSM \u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index a93420bf6ae..8402a3c1d3e 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client, config_validation as cv -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import DiscoveryInfoType from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN @@ -167,7 +167,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_authenticate() - async def async_step_reauth(self, entry_data: ConfigType) -> FlowResult: + async def async_step_reauth(self, entry_data: dict[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._name = entry_data[CONF_HOST] self._input = { diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index 68a3fbdbd39..ea7fc628e76 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -14,10 +14,10 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, PERCENTAGE, TEMP_CELSIUS, - VOLT, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -48,10 +48,10 @@ async def async_setup_entry( BridgeCpuSpeedSensor(coordinator, bridge), BridgeCpuTemperatureSensor(coordinator, bridge), BridgeCpuVoltageSensor(coordinator, bridge), - *[ + *( BridgeFilesystemSensor(coordinator, bridge, key) for key, _ in bridge.filesystem.fsSize.items() - ], + ), BridgeMemoryFreeSensor(coordinator, bridge), BridgeMemoryUsedSensor(coordinator, bridge), BridgeMemoryUsedPercentageSensor(coordinator, bridge), @@ -205,7 +205,7 @@ class BridgeCpuVoltageSensor(BridgeSensor): "CPU Voltage", None, DEVICE_CLASS_VOLTAGE, - VOLT, + ELECTRIC_POTENTIAL_VOLT, False, ) @@ -223,25 +223,26 @@ class BridgeFilesystemSensor(BridgeSensor): self, coordinator: DataUpdateCoordinator, bridge: Bridge, key: str ) -> None: """Initialize System Bridge sensor.""" + uid_key = key.replace(":", "") super().__init__( coordinator, bridge, - f"filesystem_{key}", + f"filesystem_{uid_key}", f"{key} Space Used", "mdi:harddisk", None, PERCENTAGE, True, ) - self._key = key + self._fs_key = key @property def state(self) -> float: """Return the state of the sensor.""" bridge: Bridge = self.coordinator.data return ( - round(bridge.filesystem.fsSize[self._key]["use"], 2) - if bridge.filesystem.fsSize[self._key]["use"] is not None + round(bridge.filesystem.fsSize[self._fs_key]["use"], 2) + if bridge.filesystem.fsSize[self._fs_key]["use"] is not None else None ) @@ -250,12 +251,12 @@ class BridgeFilesystemSensor(BridgeSensor): """Return the state attributes of the entity.""" bridge: Bridge = self.coordinator.data return { - ATTR_AVAILABLE: bridge.filesystem.fsSize[self._key]["available"], - ATTR_FILESYSTEM: bridge.filesystem.fsSize[self._key]["fs"], - ATTR_MOUNT: bridge.filesystem.fsSize[self._key]["mount"], - ATTR_SIZE: bridge.filesystem.fsSize[self._key]["size"], - ATTR_TYPE: bridge.filesystem.fsSize[self._key]["type"], - ATTR_USED: bridge.filesystem.fsSize[self._key]["used"], + ATTR_AVAILABLE: bridge.filesystem.fsSize[self._fs_key]["available"], + ATTR_FILESYSTEM: bridge.filesystem.fsSize[self._fs_key]["fs"], + ATTR_MOUNT: bridge.filesystem.fsSize[self._fs_key]["mount"], + ATTR_SIZE: bridge.filesystem.fsSize[self._fs_key]["size"], + ATTR_TYPE: bridge.filesystem.fsSize[self._fs_key]["type"], + ATTR_USED: bridge.filesystem.fsSize[self._fs_key]["used"], } diff --git a/homeassistant/components/system_bridge/translations/fr.json b/homeassistant/components/system_bridge/translations/fr.json index 187360bac5e..a21fab81777 100644 --- a/homeassistant/components/system_bridge/translations/fr.json +++ b/homeassistant/components/system_bridge/translations/fr.json @@ -1,7 +1,14 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie", + "unknown": "Erreur inattendue" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" }, "flow_title": "Pont syst\u00e8me: {name}", "step": { diff --git a/homeassistant/components/system_bridge/translations/hu.json b/homeassistant/components/system_bridge/translations/hu.json index e8940bef26a..50643ca5e95 100644 --- a/homeassistant/components/system_bridge/translations/hu.json +++ b/homeassistant/components/system_bridge/translations/hu.json @@ -1,5 +1,32 @@ { "config": { - "flow_title": "{name}" - } + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajhiteles\u00edt\u00e9s sikeres volt", + "unknown": "V\u00e1ratlan hiba" + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "api_key": "API kulcs" + }, + "description": "K\u00e9rj\u00fck, adja meg a(z) {name} konfigur\u00e1ci\u00f3j\u00e1ban be\u00e1ll\u00edtott API-kulcsot." + }, + "user": { + "data": { + "api_key": "API kulcs", + "host": "Gazdag\u00e9p", + "port": "Port" + }, + "description": "K\u00e9rj\u00fck, adja meg kapcsolati adatait." + } + } + }, + "title": "Rendszer h\u00edd" } \ No newline at end of file diff --git a/homeassistant/components/system_bridge/translations/id.json b/homeassistant/components/system_bridge/translations/id.json new file mode 100644 index 00000000000..9995253cbca --- /dev/null +++ b/homeassistant/components/system_bridge/translations/id.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name}", + "step": { + "authenticate": { + "data": { + "api_key": "Kunci API" + }, + "description": "Masukkan Kunci API yang Anda atur dalam konfigurasi Anda untuk {name}." + }, + "user": { + "data": { + "api_key": "Kunci API", + "host": "Host", + "port": "Port" + }, + "description": "Masukkan detail koneksi Anda." + } + } + }, + "title": "Jembatan Sistem" +} \ No newline at end of file diff --git a/homeassistant/components/system_health/translations/he.json b/homeassistant/components/system_health/translations/he.json index 2c46fb48c7d..ae5bdc2388a 100644 --- a/homeassistant/components/system_health/translations/he.json +++ b/homeassistant/components/system_health/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea" + "title": "\u05d1\u05e8\u05d9\u05d0\u05d5\u05ea \u05d4\u05de\u05e2\u05e8\u05db\u05ea" } \ No newline at end of file diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index db42df62154..3cb2abe90f6 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -56,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False except RuntimeError as exc: _LOGGER.error("Failed to setup tado: %s", exc) - return ConfigEntryNotReady + return False except requests.exceptions.Timeout as ex: raise ConfigEntryNotReady from ex except requests.exceptions.HTTPError as ex: diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index 4a410c6b30b..c05b4416343 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -7,11 +7,12 @@ import uuid import voluptuous as vol from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass import homeassistant.util.dt as dt_util @@ -75,7 +76,7 @@ class TagStorageCollection(collection.StorageCollection): return data @callback - def _get_suggested_id(self, info: dict) -> str: + def _get_suggested_id(self, info: dict[str, str]) -> str: """Suggest an ID based on the config.""" return info[TAG_ID] @@ -88,7 +89,7 @@ class TagStorageCollection(collection.StorageCollection): return data -async def async_setup(hass: HomeAssistant, config: dict): +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Tag component.""" hass.data[DOMAIN] = {} id_manager = TagIDManager() @@ -106,7 +107,9 @@ async def async_setup(hass: HomeAssistant, config: dict): @bind_hass -async def async_scan_tag(hass, tag_id, device_id, context=None): +async def async_scan_tag( + hass: HomeAssistant, tag_id: str, device_id: str, context: Context | None = None +) -> None: """Handle when a tag is scanned.""" if DOMAIN not in hass.config.components: raise HomeAssistantError("tag component has not been set up.") diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py index 1984505f3a6..ba90f0a9396 100644 --- a/homeassistant/components/tag/trigger.py +++ b/homeassistant/components/tag/trigger.py @@ -1,9 +1,11 @@ """Support for tag triggers.""" import voluptuous as vol +from homeassistant.components.automation import AutomationActionType from homeassistant.const import CONF_PLATFORM -from homeassistant.core import HassJob +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, TAG_ID @@ -16,7 +18,12 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( ) -async def async_attach_trigger(hass, config, action, automation_info): +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: """Listen for tag_scanned events based on configuration.""" trigger_data = automation_info.get("trigger_data", {}) if automation_info else {} tag_ids = set(config[TAG_ID]) @@ -24,7 +31,7 @@ async def async_attach_trigger(hass, config, action, automation_info): job = HassJob(action) - async def handle_event(event): + async def handle_event(event: Event) -> None: """Listen for tag scan events and calls the action when data matches.""" if event.data.get(TAG_ID) not in tag_ids or ( device_ids is not None and event.data.get(DEVICE_ID) not in device_ids diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py index af7f9222c50..5599f887f8f 100644 --- a/homeassistant/components/tasmota/__init__.py +++ b/homeassistant/components/tasmota/__init__.py @@ -1,4 +1,6 @@ """The Tasmota integration.""" +from __future__ import annotations + import asyncio import logging @@ -10,6 +12,7 @@ from hatasmota.const import ( CONF_SW_VERSION, ) from hatasmota.discovery import clear_discovery_topic +from hatasmota.models import TasmotaDeviceConfig from hatasmota.mqtt import TasmotaMQTTClient import voluptuous as vol @@ -18,10 +21,13 @@ from homeassistant.components.mqtt.subscription import ( async_subscribe_topics, async_unsubscribe_topics, ) -from homeassistant.core import callback +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, EVENT_DEVICE_REGISTRY_UPDATED, + DeviceRegistry, async_entries_for_config_entry, ) @@ -37,33 +43,38 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tasmota from a config entry.""" websocket_api.async_register_command(hass, websocket_remove_device) hass.data[DATA_UNSUB] = [] - def _publish(*args, **kwds): - mqtt.async_publish(hass, *args, **kwds) + def _publish( + topic: str, + payload: mqtt.PublishPayloadType, + qos: int | None = None, + retain: bool | None = None, + ) -> None: + mqtt.async_publish(hass, topic, payload, qos, retain) - async def _subscribe_topics(sub_state, topics): + async def _subscribe_topics(sub_state: dict | None, topics: dict) -> dict: # Optionally mark message handlers as callback for topic in topics.values(): if "msg_callback" in topic and "event_loop_safe" in topic: topic["msg_callback"] = callback(topic["msg_callback"]) return await async_subscribe_topics(hass, sub_state, topics) - async def _unsubscribe_topics(sub_state): + async def _unsubscribe_topics(sub_state: dict | None) -> dict: return await async_unsubscribe_topics(hass, sub_state) tasmota_mqtt = TasmotaMQTTClient(_publish, _subscribe_topics, _unsubscribe_topics) device_registry = await hass.helpers.device_registry.async_get_registry() - def async_discover_device(config, mac): + def async_discover_device(config: TasmotaDeviceConfig, mac: str) -> None: """Discover and add a Tasmota device.""" async_setup_device(hass, mac, config, entry, tasmota_mqtt, device_registry) - async def async_device_removed(event): + async def async_device_removed(event: Event) -> None: """Handle the removal of a device.""" device_registry = await hass.helpers.device_registry.async_get_registry() if event.data["action"] != "remove": @@ -82,13 +93,13 @@ async def async_setup_entry(hass, entry): hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) ) - async def start_platforms(): + async def start_platforms() -> None: await device_automation.async_setup_entry(hass, entry) await asyncio.gather( - *[ + *( hass.config_entries.async_forward_entry_setup(entry, platform) for platform in PLATFORMS - ] + ) ) discovery_prefix = entry.data[CONF_DISCOVERY_PREFIX] @@ -100,7 +111,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" # cleanup platforms @@ -127,7 +138,13 @@ async def async_unload_entry(hass, entry): return True -def _remove_device(hass, config_entry, mac, tasmota_mqtt, device_registry): +def _remove_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + mac: str, + tasmota_mqtt: TasmotaMQTTClient, + device_registry: DeviceRegistry, +) -> None: """Remove device from device registry.""" device = device_registry.async_get_device(set(), {(CONNECTION_NETWORK_MAC, mac)}) @@ -139,22 +156,32 @@ def _remove_device(hass, config_entry, mac, tasmota_mqtt, device_registry): clear_discovery_topic(mac, config_entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt) -def _update_device(hass, config_entry, config, device_registry): +def _update_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + config: TasmotaDeviceConfig, + device_registry: DeviceRegistry, +) -> None: """Add or update device registry.""" - config_entry_id = config_entry.entry_id - device_info = { - "connections": {(CONNECTION_NETWORK_MAC, config[CONF_MAC])}, - "manufacturer": config[CONF_MANUFACTURER], - "model": config[CONF_MODEL], - "name": config[CONF_NAME], - "sw_version": config[CONF_SW_VERSION], - "config_entry_id": config_entry_id, - } _LOGGER.debug("Adding or updating tasmota device %s", config[CONF_MAC]) - device_registry.async_get_or_create(**device_info) + device_registry.async_get_or_create( + connections={(CONNECTION_NETWORK_MAC, config[CONF_MAC])}, + manufacturer=config[CONF_MANUFACTURER], + model=config[CONF_MODEL], + name=config[CONF_NAME], + sw_version=config[CONF_SW_VERSION], + config_entry_id=config_entry.entry_id, + ) -def async_setup_device(hass, mac, config, config_entry, tasmota_mqtt, device_registry): +def async_setup_device( + hass: HomeAssistant, + mac: str, + config: TasmotaDeviceConfig, + config_entry: ConfigEntry, + tasmota_mqtt: TasmotaMQTTClient, + device_registry: DeviceRegistry, +) -> None: """Set up the Tasmota device.""" if not config: _remove_device(hass, config_entry, mac, tasmota_mqtt, device_registry) @@ -166,7 +193,9 @@ def async_setup_device(hass, mac, config, config_entry, tasmota_mqtt, device_reg {vol.Required("type"): "tasmota/device/remove", vol.Required("device_id"): str} ) @websocket_api.async_response -async def websocket_remove_device(hass, connection, msg): +async def websocket_remove_device( + hass: HomeAssistant, connection: ActiveConnection, msg: dict +) -> None: """Delete device.""" device_id = msg["device_id"] dev_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py index feaafa72b29..1ccee0bf7d3 100644 --- a/homeassistant/components/tasmota/binary_sensor.py +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -1,9 +1,19 @@ """Support for Tasmota binary sensors.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable + +from hatasmota import switch as tasmota_switch +from hatasmota.entity import TasmotaEntity as HATasmotaEntity +from hatasmota.models import DiscoveryHashType from homeassistant.components import binary_sensor from homeassistant.components.binary_sensor import BinarySensorEntity -from homeassistant.core import callback +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 import homeassistant.helpers.event as evt from .const import DATA_REMOVE_DISCOVER_COMPONENT @@ -11,11 +21,17 @@ from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota binary sensor dynamically through discovery.""" @callback - def async_discover(tasmota_entity, discovery_hash): + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota binary sensor.""" async_add_entities( [ @@ -41,33 +57,40 @@ class TasmotaBinarySensor( ): """Representation a Tasmota binary sensor.""" - def __init__(self, **kwds): + _tasmota_entity: tasmota_switch.TasmotaSwitch + + def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota binary sensor.""" - self._delay_listener = None - self._state = None + self._delay_listener: Callable | None = None + self._on_off_state: bool | None = None super().__init__( **kwds, ) + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.on_off_state_updated) + await super().async_added_to_hass() + @callback - def off_delay_listener(self, now): + def off_delay_listener(self, now: datetime) -> None: """Switch device off after a delay.""" self._delay_listener = None - self._state = False + self._on_off_state = False self.async_write_ha_state() @callback - def state_updated(self, state, **kwargs): + def on_off_state_updated(self, state: bool, **kwargs: Any) -> None: """Handle state updates.""" - self._state = state + self._on_off_state = state if self._delay_listener is not None: self._delay_listener() self._delay_listener = None off_delay = self._tasmota_entity.off_delay - if self._state and off_delay is not None: + if self._on_off_state and off_delay is not None: self._delay_listener = evt.async_call_later( self.hass, off_delay, self.off_delay_listener ) @@ -75,11 +98,11 @@ class TasmotaBinarySensor( self.async_write_ha_state() @property - def force_update(self): + def force_update(self) -> bool: """Force update.""" return True @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return self._state + return self._on_off_state diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py index 2f15b73c6e9..e1621f2c126 100644 --- a/homeassistant/components/tasmota/config_flow.py +++ b/homeassistant/components/tasmota/config_flow.py @@ -1,8 +1,14 @@ """Config flow for Tasmota.""" +from __future__ import annotations + +from typing import Any, cast + import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.mqtt import valid_subscribe_topic +from homeassistant.components.mqtt import ReceiveMessage, valid_subscribe_topic +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.typing import DiscoveryInfoType from .const import CONF_DISCOVERY_PREFIX, DEFAULT_PREFIX, DOMAIN @@ -12,11 +18,11 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" self._prefix = DEFAULT_PREFIX - async def async_step_mqtt(self, discovery_info=None): + async def async_step_mqtt(self, discovery_info: DiscoveryInfoType) -> FlowResult: """Handle a flow initialized by MQTT discovery.""" if self._async_in_progress() or self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -24,7 +30,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) # Validate the topic, will throw if it fails - prefix = discovery_info.subscribed_topic + prefix = cast(ReceiveMessage, discovery_info).subscribed_topic if prefix.endswith("/#"): prefix = prefix[:-2] try: @@ -36,7 +42,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -45,7 +53,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_config() return await self.async_step_confirm() - async def async_step_config(self, user_input=None): + async def async_step_config( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm the setup.""" errors = {} data = {CONF_DISCOVERY_PREFIX: self._prefix} @@ -72,7 +82,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="config", data_schema=vol.Schema(fields), errors=errors ) - async def async_step_confirm(self, user_input=None): + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Confirm the setup.""" data = {CONF_DISCOVERY_PREFIX: self._prefix} diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 681778d0099..458c712ae3d 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -1,22 +1,35 @@ """Support for Tasmota covers.""" +from __future__ import annotations -from hatasmota import const as tasmota_const +from typing import Any + +from hatasmota import const as tasmota_const, shutter as tasmota_shutter +from hatasmota.entity import TasmotaEntity as HATasmotaEntity +from hatasmota.models import DiscoveryHashType from homeassistant.components import cover from homeassistant.components.cover import CoverEntity -from homeassistant.core import callback +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_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota cover dynamically through discovery.""" @callback - def async_discover(tasmota_entity, discovery_hash): + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota cover.""" async_add_entities( [TasmotaCover(tasmota_entity=tasmota_entity, discovery_hash=discovery_hash)] @@ -38,24 +51,31 @@ class TasmotaCover( ): """Representation of a Tasmota cover.""" - def __init__(self, **kwds): + _tasmota_entity: tasmota_shutter.TasmotaShutter + + def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota cover.""" - self._direction = None - self._position = None + self._direction: int | None = None + self._position: int | None = None super().__init__( **kwds, ) + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.cover_state_updated) + await super().async_added_to_hass() + @callback - def state_updated(self, state, **kwargs): + def cover_state_updated(self, state: bool, **kwargs: Any) -> None: """Handle state updates.""" self._direction = kwargs["direction"] self._position = kwargs["position"] self.async_write_ha_state() @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -63,7 +83,7 @@ class TasmotaCover( return self._position @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return ( cover.SUPPORT_OPEN @@ -73,35 +93,35 @@ class TasmotaCover( ) @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._direction == tasmota_const.SHUTTER_DIRECTION_UP @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._direction == tasmota_const.SHUTTER_DIRECTION_DOWN @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._position is None: return None return self._position == 0 - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._tasmota_entity.open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" self._tasmota_entity.close() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[cover.ATTR_POSITION] self._tasmota_entity.set_position(position) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._tasmota_entity.stop() diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py index ff431141bef..9b190855ad2 100644 --- a/homeassistant/components/tasmota/device_automation.py +++ b/homeassistant/components/tasmota/device_automation.py @@ -1,7 +1,11 @@ """Provides device automations for Tasmota.""" from hatasmota.const import AUTOMATION_TYPE_TRIGGER +from hatasmota.models import DiscoveryHashType +from hatasmota.trigger import TasmotaTrigger +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -10,21 +14,23 @@ from .const import DATA_REMOVE_DISCOVER_COMPONENT, DATA_UNSUB from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -async def async_remove_automations(hass, device_id): +async def async_remove_automations(hass: HomeAssistant, device_id: str) -> None: """Remove automations for a Tasmota device.""" await device_trigger.async_remove_triggers(hass, device_id) -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Set up Tasmota device automation dynamically through discovery.""" - async def async_device_removed(event): + async def async_device_removed(event: Event) -> None: """Handle the removal of a device.""" if event.data["action"] != "remove": return await async_remove_automations(hass, event.data["device_id"]) - async def async_discover(tasmota_automation, discovery_hash): + async def async_discover( + tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota device automation.""" if tasmota_automation.automation_type == AUTOMATION_TYPE_TRIGGER: await device_trigger.async_setup_trigger( diff --git a/homeassistant/components/tasmota/device_trigger.py b/homeassistant/components/tasmota/device_trigger.py index 6a6f0324e1d..eb95ca2bf64 100644 --- a/homeassistant/components/tasmota/device_trigger.py +++ b/homeassistant/components/tasmota/device_trigger.py @@ -5,12 +5,14 @@ import logging from typing import Callable import attr -from hatasmota.trigger import TasmotaTrigger +from hatasmota.models import DiscoveryHashType +from hatasmota.trigger import TasmotaTrigger, TasmotaTriggerConfig import voluptuous as vol from homeassistant.components.automation import AutomationActionType from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import event as event_trigger +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -51,8 +53,9 @@ class TriggerInstance: trigger: Trigger = attr.ib() remove: CALLBACK_TYPE | None = attr.ib(default=None) - async def async_attach_trigger(self): + async def async_attach_trigger(self) -> None: """Attach event trigger.""" + assert self.trigger.tasmota_trigger is not None event_config = { event_trigger.CONF_PLATFORM: "event", event_trigger.CONF_EVENT_TYPE: TASMOTA_EVENT, @@ -81,15 +84,17 @@ class Trigger: """Device trigger settings.""" device_id: str = attr.ib() - discovery_hash: dict = attr.ib() + discovery_hash: DiscoveryHashType | None = attr.ib() hass: HomeAssistant = attr.ib() - remove_update_signal: Callable[[], None] = attr.ib() + remove_update_signal: Callable[[], None] | None = attr.ib() subtype: str = attr.ib() - tasmota_trigger: TasmotaTrigger = attr.ib() + tasmota_trigger: TasmotaTrigger | None = attr.ib() type: str = attr.ib() trigger_instances: list[TriggerInstance] = attr.ib(factory=list) - async def add_trigger(self, action, automation_info): + async def add_trigger( + self, action: AutomationActionType, automation_info: dict + ) -> Callable[[], None]: """Add Tasmota trigger.""" instance = TriggerInstance(action, automation_info, self) self.trigger_instances.append(instance) @@ -110,7 +115,7 @@ class Trigger: return async_remove - def detach_trigger(self): + def detach_trigger(self) -> None: """Remove Tasmota device trigger.""" # Mark trigger as unknown self.tasmota_trigger = None @@ -121,11 +126,12 @@ class Trigger: trig.remove() trig.remove = None - async def arm_tasmota_trigger(self): + async def arm_tasmota_trigger(self) -> None: """Arm Tasmota trigger: subscribe to MQTT topics and fire events.""" @callback - def _on_trigger(): + def _on_trigger() -> None: + assert self.tasmota_trigger is not None data = { "mac": self.tasmota_trigger.cfg.mac, "source": self.tasmota_trigger.cfg.subtype, @@ -136,10 +142,13 @@ class Trigger: data, ) + assert self.tasmota_trigger is not None self.tasmota_trigger.set_on_trigger_callback(_on_trigger) await self.tasmota_trigger.subscribe_topics() - async def set_tasmota_trigger(self, tasmota_trigger, remove_update_signal): + async def set_tasmota_trigger( + self, tasmota_trigger: TasmotaTrigger, remove_update_signal: Callable[[], None] + ) -> None: """Set Tasmota trigger.""" await self.update_tasmota_trigger(tasmota_trigger.cfg, remove_update_signal) self.tasmota_trigger = tasmota_trigger @@ -147,22 +156,31 @@ class Trigger: for trig in self.trigger_instances: await trig.async_attach_trigger() - async def update_tasmota_trigger(self, tasmota_trigger_cfg, remove_update_signal): + async def update_tasmota_trigger( + self, + tasmota_trigger_cfg: TasmotaTriggerConfig, + remove_update_signal: Callable[[], None], + ) -> None: """Update Tasmota trigger.""" self.remove_update_signal = remove_update_signal self.type = tasmota_trigger_cfg.type self.subtype = tasmota_trigger_cfg.subtype -async def async_setup_trigger(hass, tasmota_trigger, config_entry, discovery_hash): +async def async_setup_trigger( + hass: HomeAssistant, + tasmota_trigger: TasmotaTrigger, + config_entry: ConfigEntry, + discovery_hash: DiscoveryHashType, +) -> None: """Set up a discovered Tasmota device trigger.""" discovery_id = tasmota_trigger.cfg.trigger_id - remove_update_signal = None + remove_update_signal: Callable[[], None] | None = None _LOGGER.debug( "Discovered trigger with ID: %s '%s'", discovery_id, tasmota_trigger.cfg ) - async def discovery_update(trigger_config): + async def discovery_update(trigger_config: TasmotaTriggerConfig) -> None: """Handle discovery update.""" _LOGGER.debug( "Got update for trigger with hash: %s '%s'", discovery_hash, trigger_config @@ -175,7 +193,8 @@ async def async_setup_trigger(hass, tasmota_trigger, config_entry, discovery_has await device_trigger.tasmota_trigger.unsubscribe_topics() device_trigger.detach_trigger() clear_discovery_hash(hass, discovery_hash) - remove_update_signal() + if remove_update_signal is not None: + remove_update_signal() return device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] @@ -226,7 +245,7 @@ async def async_setup_trigger(hass, tasmota_trigger, config_entry, discovery_has await device_trigger.arm_tasmota_trigger() -async def async_remove_triggers(hass: HomeAssistant, device_id: str): +async def async_remove_triggers(hass: HomeAssistant, device_id: str) -> None: """Cleanup any device triggers for a Tasmota device.""" triggers = await async_get_triggers(hass, device_id) for trig in triggers: @@ -242,7 +261,7 @@ async def async_remove_triggers(hass: HomeAssistant, device_id: str): async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: """List device triggers for a Tasmota device.""" - triggers = [] + triggers: list[dict[str, str]] = [] if DEVICE_TRIGGERS not in hass.data: return triggers @@ -287,6 +306,5 @@ async def async_attach_trigger( subtype=config[CONF_SUBTYPE], tasmota_trigger=None, ) - return await hass.data[DEVICE_TRIGGERS][discovery_id].add_trigger( - action, automation_info - ) + trigger: Trigger = hass.data[DEVICE_TRIGGERS][discovery_id] + return await trigger.add_trigger(action, automation_info) diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index b69307693da..37b373d30a1 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -1,5 +1,8 @@ """Support for Tasmota device discovery.""" +from __future__ import annotations + import logging +from typing import Callable from hatasmota.discovery import ( TasmotaDiscovery, @@ -10,8 +13,13 @@ from hatasmota.discovery import ( get_triggers as tasmota_get_triggers, unique_id_from_hash, ) +from hatasmota.entity import TasmotaEntityConfig +from hatasmota.models import DiscoveryHashType, TasmotaDeviceConfig +from hatasmota.mqtt import TasmotaMQTTClient +from hatasmota.sensor import TasmotaBaseSensorConfig -import homeassistant.components.sensor as sensor +from homeassistant.components import sensor +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dev_reg from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,8 +34,12 @@ TASMOTA_DISCOVERY_ENTITY_NEW = "tasmota_discovery_entity_new_{}" TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}_{}" TASMOTA_DISCOVERY_INSTANCE = "tasmota_discovery_instance" +SetupDeviceCallback = Callable[[TasmotaDeviceConfig, str], None] -def clear_discovery_hash(hass, discovery_hash): + +def clear_discovery_hash( + hass: HomeAssistant, discovery_hash: DiscoveryHashType +) -> None: """Clear entry in ALREADY_DISCOVERED list.""" if ALREADY_DISCOVERED not in hass.data: # Discovery is shutting down @@ -35,17 +47,25 @@ def clear_discovery_hash(hass, discovery_hash): del hass.data[ALREADY_DISCOVERED][discovery_hash] -def set_discovery_hash(hass, discovery_hash): +def set_discovery_hash(hass: HomeAssistant, discovery_hash: DiscoveryHashType) -> None: """Set entry in ALREADY_DISCOVERED list.""" hass.data[ALREADY_DISCOVERED][discovery_hash] = {} async def async_start( - hass: HomeAssistant, discovery_topic, config_entry, tasmota_mqtt, setup_device -) -> bool: + hass: HomeAssistant, + discovery_topic: str, + config_entry: ConfigEntry, + tasmota_mqtt: TasmotaMQTTClient, + setup_device: SetupDeviceCallback, +) -> None: """Start Tasmota device discovery.""" - async def _discover_entity(tasmota_entity_config, discovery_hash, platform): + async def _discover_entity( + tasmota_entity_config: TasmotaEntityConfig | None, + discovery_hash: DiscoveryHashType, + platform: str, + ) -> None: """Handle adding or updating a discovered entity.""" if not tasmota_entity_config: # Entity disabled, clean up entity registry @@ -70,6 +90,10 @@ async def async_start( ) else: tasmota_entity = tasmota_get_entity(tasmota_entity_config, tasmota_mqtt) + if not tasmota_entity: + _LOGGER.error("Failed to create entity %s %s", platform, discovery_hash) + return + _LOGGER.debug( "Adding new entity: %s %s %s", platform, @@ -86,7 +110,7 @@ async def async_start( discovery_hash, ) - async def async_device_discovered(payload, mac): + async def async_device_discovered(payload: dict, mac: str) -> None: """Process the received message.""" if ALREADY_DISCOVERED not in hass.data: @@ -102,7 +126,12 @@ async def async_start( tasmota_triggers = tasmota_get_triggers(payload) for trigger_config in tasmota_triggers: - discovery_hash = (mac, "automation", "trigger", trigger_config.trigger_id) + discovery_hash: DiscoveryHashType = ( + mac, + "automation", + "trigger", + trigger_config.trigger_id, + ) if discovery_hash in hass.data[ALREADY_DISCOVERED]: _LOGGER.debug( "Trigger already added, sending update: %s", @@ -131,7 +160,9 @@ async def async_start( for (tasmota_entity_config, discovery_hash) in tasmota_entities: await _discover_entity(tasmota_entity_config, discovery_hash, platform) - async def async_sensors_discovered(sensors, mac): + async def async_sensors_discovered( + sensors: list[tuple[TasmotaBaseSensorConfig, DiscoveryHashType]], mac: str + ) -> None: """Handle discovery of (additional) sensors.""" platform = sensor.DOMAIN @@ -171,7 +202,7 @@ async def async_start( hass.data[TASMOTA_DISCOVERY_INSTANCE] = tasmota_discovery -async def async_stop(hass: HomeAssistant) -> bool: +async def async_stop(hass: HomeAssistant) -> None: """Stop Tasmota device discovery.""" hass.data.pop(ALREADY_DISCOVERED) tasmota_discovery = hass.data.pop(TASMOTA_DISCOVERY_INSTANCE) diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py index 876d1a4cf60..92399fa1bbc 100644 --- a/homeassistant/components/tasmota/fan.py +++ b/homeassistant/components/tasmota/fan.py @@ -1,11 +1,18 @@ """Support for Tasmota fans.""" +from __future__ import annotations -from hatasmota import const as tasmota_const +from typing import Any + +from hatasmota import const as tasmota_const, fan as tasmota_fan +from hatasmota.entity import TasmotaEntity as HATasmotaEntity +from hatasmota.models import DiscoveryHashType from homeassistant.components import fan from homeassistant.components.fan import FanEntity -from homeassistant.core import callback +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 homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -22,11 +29,17 @@ ORDERED_NAMED_FAN_SPEEDS = [ ] # off is not included -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota fan dynamically through discovery.""" @callback - def async_discover(tasmota_entity, discovery_hash): + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota fan.""" async_add_entities( [TasmotaFan(tasmota_entity=tasmota_entity, discovery_hash=discovery_hash)] @@ -48,21 +61,34 @@ class TasmotaFan( ): """Representation of a Tasmota fan.""" - def __init__(self, **kwds): + _tasmota_entity: tasmota_fan.TasmotaFan + + def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota fan.""" - self._state = None + self._state: int | None = None super().__init__( **kwds, ) + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.fan_state_updated) + await super().async_added_to_hass() + + @callback + def fan_state_updated(self, state: int, **kwargs: Any) -> None: + """Handle state updates.""" + self._state = state + self.async_write_ha_state() + @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" return len(ORDERED_NAMED_FAN_SPEEDS) @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed percentage.""" if self._state is None: return None @@ -71,11 +97,11 @@ class TasmotaFan( return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, self._state) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return fan.SUPPORT_SET_SPEED - async def async_set_percentage(self, percentage): + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan.""" if percentage == 0: await self.async_turn_off() @@ -86,8 +112,12 @@ class TasmotaFan( self._tasmota_entity.set_speed(tasmota_speed) async def async_turn_on( - self, speed=None, percentage=None, preset_mode=None, **kwargs - ): + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the fan on.""" # Tasmota does not support turning a fan on with implicit speed await self.async_set_percentage( @@ -97,6 +127,6 @@ class TasmotaFan( ) ) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" self._tasmota_entity.set_speed(tasmota_const.FAN_SPEED_OFF) diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py index 9af95049f79..de25a25fd4f 100644 --- a/homeassistant/components/tasmota/light.py +++ b/homeassistant/components/tasmota/light.py @@ -1,4 +1,10 @@ """Support for Tasmota lights.""" +from __future__ import annotations + +from typing import Any + +from hatasmota import light as tasmota_light +from hatasmota.entity import TasmotaEntity as HATasmotaEntity, TasmotaEntityConfig from hatasmota.light import ( LIGHT_TYPE_COLDWARM, LIGHT_TYPE_NONE, @@ -6,6 +12,7 @@ from hatasmota.light import ( LIGHT_TYPE_RGBCW, LIGHT_TYPE_RGBW, ) +from hatasmota.models import DiscoveryHashType from homeassistant.components import light from homeassistant.components.light import ( @@ -25,22 +32,30 @@ from homeassistant.components.light import ( LightEntity, brightness_supported, ) -from homeassistant.core import callback +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_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity DEFAULT_BRIGHTNESS_MAX = 255 TASMOTA_BRIGHTNESS_MAX = 100 -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota light dynamically through discovery.""" @callback - def async_discover(tasmota_entity, discovery_hash): + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota light.""" async_add_entities( [TasmotaLight(tasmota_entity=tasmota_entity, discovery_hash=discovery_hash)] @@ -55,12 +70,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) -def clamp(value): +def clamp(value: float) -> float: """Clamp value to the range 0..255.""" return min(max(value, 0), 255) -def scale_brightness(brightness): +def scale_brightness(brightness: float) -> float: """Scale brightness from 0..255 to 1..100.""" brightness_normalized = brightness / DEFAULT_BRIGHTNESS_MAX device_brightness = min( @@ -74,23 +89,25 @@ def scale_brightness(brightness): class TasmotaLight( TasmotaAvailability, TasmotaDiscoveryUpdate, + TasmotaOnOffEntity, LightEntity, ): """Representation of a Tasmota light.""" - def __init__(self, **kwds): + _tasmota_entity: tasmota_light.TasmotaLight + + def __init__(self, **kwds: Any) -> None: """Initialize Tasmota light.""" - self._state = False - self._supported_color_modes = None + self._supported_color_modes: set[str] | None = None self._supported_features = 0 - self._brightness = None - self._color_mode = None - self._color_temp = None - self._effect = None - self._white_value = None + self._brightness: int | None = None + self._color_mode: str | None = None + self._color_temp: int | None = None + self._effect: str | None = None + self._white_value: int | None = None self._flash_times = None - self._hs = None + self._hs: tuple[float, float] | None = None super().__init__( **kwds, @@ -98,13 +115,15 @@ class TasmotaLight( self._setup_from_entity() - async def discovery_update(self, update, write_state=True): + async def discovery_update( + self, update: TasmotaEntityConfig, write_state: bool = True + ) -> None: """Handle updated discovery message.""" await super().discovery_update(update, write_state=False) self._setup_from_entity() self.async_write_ha_state() - def _setup_from_entity(self): + def _setup_from_entity(self) -> None: """(Re)Setup the entity.""" self._supported_color_modes = set() supported_features = 0 @@ -140,15 +159,15 @@ class TasmotaLight( self._supported_features = supported_features @callback - def state_updated(self, state, **kwargs): + def state_updated(self, state: bool, **kwargs: Any) -> None: """Handle state updates.""" - self._state = state + self._on_off_state = state attributes = kwargs.get("attributes") if attributes: if "brightness" in attributes: brightness = float(attributes["brightness"]) percent_bright = brightness / TASMOTA_BRIGHTNESS_MAX - self._brightness = percent_bright * 255 + self._brightness = round(percent_bright * 255) if "color_hs" in attributes: self._hs = attributes["color_hs"] if "color_temp" in attributes: @@ -158,7 +177,7 @@ class TasmotaLight( if "white_value" in attributes: white_value = float(attributes["white_value"]) percent_white = white_value / TASMOTA_BRIGHTNESS_MAX - self._white_value = percent_white * 255 + self._white_value = round(percent_white * 255) if self._tasmota_entity.light_type == LIGHT_TYPE_RGBW: # Tasmota does not support RGBW mode, set mode to white or hs if self._white_value == 0: @@ -175,73 +194,68 @@ class TasmotaLight( self.async_write_ha_state() @property - def brightness(self): + def brightness(self) -> int | None: """Return the brightness of this light between 0..255.""" return self._brightness @property - def color_mode(self): + def color_mode(self) -> str | None: """Return the color mode of the light.""" return self._color_mode @property - def color_temp(self): + def color_temp(self) -> int | None: """Return the color temperature in mired.""" return self._color_temp @property - def min_mireds(self): + def min_mireds(self) -> int: """Return the coldest color_temp that this light supports.""" return self._tasmota_entity.min_mireds @property - def max_mireds(self): + def max_mireds(self) -> int: """Return the warmest color_temp that this light supports.""" return self._tasmota_entity.max_mireds @property - def effect(self): + def effect(self) -> str | None: """Return the current effect.""" return self._effect @property - def effect_list(self): + def effect_list(self) -> list[str] | None: """Return the list of supported effects.""" return self._tasmota_entity.effect_list @property - def hs_color(self): + def hs_color(self) -> tuple[float, float] | None: """Return the hs color value.""" if self._hs is None: return None hs_color = self._hs - return [hs_color[0], hs_color[1]] + return (hs_color[0], hs_color[1]) @property - def force_update(self): + def force_update(self) -> bool: """Force update.""" return False @property - def is_on(self): - """Return true if device is on.""" - return self._state - - @property - def supported_color_modes(self): + def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" return self._supported_color_modes @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return self._supported_features - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - supported_color_modes = self._supported_color_modes + supported_color_modes = self._supported_color_modes or set() - attributes = {} + attributes: dict[str, Any] = {} if ATTR_HS_COLOR in kwargs and COLOR_MODE_HS in supported_color_modes: hs_color = kwargs[ATTR_HS_COLOR] @@ -264,7 +278,7 @@ class TasmotaLight( self._tasmota_entity.set_state(True, attributes) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" attributes = {"state": "OFF"} diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index f87e0c189f1..ae918c7fe44 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.2.19"], + "requirements": ["hatasmota==0.2.20"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"], diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index d8e0eeeb4cd..a07e48b53a7 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -1,5 +1,15 @@ """Tasmota entity mixins.""" +from __future__ import annotations + import logging +from typing import Any + +from hatasmota.entity import ( + TasmotaAvailability as HATasmotaAvailability, + TasmotaEntity as HATasmotaEntity, + TasmotaEntityConfig, +) +from hatasmota.models import DiscoveryHashType from homeassistant.components.mqtt import ( async_subscribe_connection_status, @@ -8,7 +18,7 @@ from homeassistant.components.mqtt import ( from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from .discovery import ( TASMOTA_DISCOVERY_ENTITY_UPDATED, @@ -22,64 +32,85 @@ _LOGGER = logging.getLogger(__name__) class TasmotaEntity(Entity): """Base class for Tasmota entities.""" - def __init__(self, tasmota_entity) -> None: + def __init__(self, tasmota_entity: HATasmotaEntity) -> None: """Initialize.""" - self._state = None self._tasmota_entity = tasmota_entity self._unique_id = tasmota_entity.unique_id - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" - self._tasmota_entity.set_on_state_callback(self.state_updated) await self._subscribe_topics() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" await self._tasmota_entity.unsubscribe_topics() await super().async_will_remove_from_hass() - async def discovery_update(self, update, write_state=True): + async def discovery_update( + self, update: TasmotaEntityConfig, write_state: bool = True + ) -> None: """Handle updated discovery message.""" self._tasmota_entity.config_update(update) await self._subscribe_topics() if write_state: self.async_write_ha_state() - async def _subscribe_topics(self): + async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await self._tasmota_entity.subscribe_topics() - @callback - def state_updated(self, state, **kwargs): - """Handle state updates.""" - self._state = state - self.async_write_ha_state() - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" return {"connections": {(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)}} @property - def name(self): + def name(self) -> str | None: """Return the name of the binary sensor.""" return self._tasmota_entity.name @property - def should_poll(self): + def should_poll(self) -> bool: """Return the polling state.""" return False @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique ID.""" return self._unique_id +class TasmotaOnOffEntity(TasmotaEntity): + """Base class for Tasmota entities which can be on or off.""" + + def __init__(self, **kwds: Any) -> None: + """Initialize.""" + self._on_off_state: bool = False + super().__init__(**kwds) + + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.state_updated) + await super().async_added_to_hass() + + @callback + def state_updated(self, state: bool, **kwargs: Any) -> None: + """Handle state updates.""" + self._on_off_state = state + self.async_write_ha_state() + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return self._on_off_state + + class TasmotaAvailability(TasmotaEntity): """Mixin used for platforms that report availability.""" - def __init__(self, **kwds) -> None: + _tasmota_entity: HATasmotaAvailability + + def __init__(self, **kwds: Any) -> None: """Initialize the availability mixin.""" self._available = False super().__init__(**kwds) @@ -100,7 +131,7 @@ class TasmotaAvailability(TasmotaEntity): self.async_write_ha_state() @callback - def async_mqtt_connected(self, _): + def async_mqtt_connected(self, _: bool) -> None: """Update state on connection/disconnection to MQTT broker.""" if not self.hass.is_stopping: if not mqtt_connected(self.hass): @@ -116,7 +147,7 @@ class TasmotaAvailability(TasmotaEntity): class TasmotaDiscoveryUpdate(TasmotaEntity): """Mixin used to handle updated discovery message.""" - def __init__(self, discovery_hash, **kwds) -> None: + def __init__(self, discovery_hash: DiscoveryHashType, **kwds: Any) -> None: """Initialize the discovery update mixin.""" self._discovery_hash = discovery_hash self._removed_from_hass = False @@ -127,7 +158,7 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): self._removed_from_hass = False await super().async_added_to_hass() - async def discovery_callback(config): + async def discovery_callback(config: TasmotaEntityConfig) -> None: """Handle discovery update.""" _LOGGER.debug( "Got update for entity with hash: %s '%s'", diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index e346f8f13ac..b756d656921 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -1,12 +1,17 @@ """Support for Tasmota sensors.""" from __future__ import annotations +from datetime import datetime import logging +from typing import Any -from hatasmota import const as hc, status_sensor +from hatasmota import const as hc, sensor as tasmota_sensor, status_sensor +from hatasmota.entity import TasmotaEntity as HATasmotaEntity +from hatasmota.models import DiscoveryHashType from homeassistant.components import sensor from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -21,14 +26,15 @@ from homeassistant.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, - ELECTRICAL_CURRENT_AMPERE, - ELECTRICAL_VOLT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, FREQUENCY_HERTZ, LENGTH_CENTIMETERS, LIGHT_LUX, MASS_KILOGRAMS, PERCENTAGE, + POWER_VOLT_AMPERE, POWER_WATT, PRESSURE_HPA, SIGNAL_STRENGTH_DECIBELS, @@ -39,10 +45,10 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_KELVIN, - VOLT, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util from .const import DATA_REMOVE_DISCOVER_COMPONENT @@ -128,8 +134,8 @@ SENSOR_UNIT_MAP = { hc.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, hc.CONCENTRATION_PARTS_PER_BILLION: CONCENTRATION_PARTS_PER_BILLION, hc.CONCENTRATION_PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - hc.ELECTRICAL_CURRENT_AMPERE: ELECTRICAL_CURRENT_AMPERE, - hc.ELECTRICAL_VOLT_AMPERE: ELECTRICAL_VOLT_AMPERE, + hc.ELECTRICAL_CURRENT_AMPERE: ELECTRIC_CURRENT_AMPERE, + hc.ELECTRICAL_VOLT_AMPERE: POWER_VOLT_AMPERE, hc.ENERGY_KILO_WATT_HOUR: ENERGY_KILO_WATT_HOUR, hc.FREQUENCY_HERTZ: FREQUENCY_HERTZ, hc.LENGTH_CENTIMETERS: LENGTH_CENTIMETERS, @@ -146,14 +152,21 @@ SENSOR_UNIT_MAP = { hc.TEMP_CELSIUS: TEMP_CELSIUS, hc.TEMP_FAHRENHEIT: TEMP_FAHRENHEIT, hc.TEMP_KELVIN: TEMP_KELVIN, - hc.VOLT: VOLT, + hc.VOLT: ELECTRIC_POTENTIAL_VOLT, } -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota sensor dynamically through discovery.""" - async def async_discover_sensor(tasmota_entity, discovery_hash): + @callback + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota sensor.""" async_add_entities( [ @@ -168,7 +181,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ] = async_dispatcher_connect( hass, TASMOTA_DISCOVERY_ENTITY_NEW.format(sensor.DOMAIN), - async_discover_sensor, + async_discover, ) @@ -176,24 +189,33 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): """Representation of a Tasmota sensor.""" _attr_last_reset = None + _tasmota_entity: tasmota_sensor.TasmotaSensor - def __init__(self, **kwds): + def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota sensor.""" - self._state = None + self._state: Any | None = None + self._state_timestamp: datetime | None = None super().__init__( **kwds, ) + async def async_added_to_hass(self) -> None: + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.sensor_state_updated) + await super().async_added_to_hass() + @callback - def state_updated(self, state, **kwargs): + def sensor_state_updated(self, state: Any, **kwargs: Any) -> None: """Handle state updates.""" - self._state = state + if self.device_class == DEVICE_CLASS_TIMESTAMP: + self._state_timestamp = state + else: + self._state = state if "last_reset" in kwargs: try: - last_reset = dt_util.as_utc( - dt_util.parse_datetime(kwargs["last_reset"]) - ) + last_reset_dt = dt_util.parse_datetime(kwargs["last_reset"]) + last_reset = dt_util.as_utc(last_reset_dt) if last_reset_dt else None if last_reset is None: raise ValueError self._attr_last_reset = last_reset @@ -228,7 +250,7 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return True @property - def icon(self): + def icon(self) -> str | None: """Return the icon.""" class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( self._tasmota_entity.quantity, {} @@ -236,18 +258,18 @@ class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, SensorEntity): return class_or_icon.get(ICON) @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" - if self._state and self.device_class == DEVICE_CLASS_TIMESTAMP: - return self._state.isoformat() + if self._state_timestamp and self.device_class == DEVICE_CLASS_TIMESTAMP: + return self._state_timestamp.isoformat() return self._state @property - def force_update(self): + def force_update(self) -> bool: """Force update.""" return True @property - def unit_of_measurement(self): + def unit_of_measurement(self) -> str | None: """Return the unit this state is expressed in.""" return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index 27906bf5dbb..50319abac56 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -1,20 +1,33 @@ """Support for Tasmota switches.""" +from typing import Any + +from hatasmota import relay as tasmota_relay +from hatasmota.entity import TasmotaEntity as HATasmotaEntity +from hatasmota.models import DiscoveryHashType from homeassistant.components import switch from homeassistant.components.switch import SwitchEntity -from homeassistant.core import callback +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_REMOVE_DISCOVER_COMPONENT from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW -from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate +from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEntity -async def async_setup_entry(hass, config_entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: """Set up Tasmota switch dynamically through discovery.""" @callback - def async_discover(tasmota_entity, discovery_hash): + def async_discover( + tasmota_entity: HATasmotaEntity, discovery_hash: DiscoveryHashType + ) -> None: """Discover and add a Tasmota switch.""" async_add_entities( [ @@ -36,27 +49,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class TasmotaSwitch( TasmotaAvailability, TasmotaDiscoveryUpdate, + TasmotaOnOffEntity, SwitchEntity, ): """Representation of a Tasmota switch.""" - def __init__(self, **kwds): - """Initialize the Tasmota switch.""" - self._state = False + _tasmota_entity: tasmota_relay.TasmotaRelay - super().__init__( - **kwds, - ) - - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" self._tasmota_entity.set_state(True) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" self._tasmota_entity.set_state(False) diff --git a/homeassistant/components/tasmota/translations/he.json b/homeassistant/components/tasmota/translations/he.json index 7853c226b33..eefc72310d4 100644 --- a/homeassistant/components/tasmota/translations/he.json +++ b/homeassistant/components/tasmota/translations/he.json @@ -3,8 +3,14 @@ "abort": { "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "error": { + "invalid_discovery_topic": "\u05d2\u05d9\u05dc\u05d5\u05d9 \u05dc\u05e4\u05d9 \u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e0\u05d5\u05e9\u05d0 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea." + }, "step": { "config": { + "data": { + "discovery_prefix": "\u05d2\u05d9\u05dc\u05d5\u05d9 \u05dc\u05e4\u05d9 \u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e0\u05d5\u05e9\u05d0" + }, "description": "\u05d0\u05e0\u05d0 \u05d4\u05db\u05e0\u05e1 \u05d0\u05ea \u05ea\u05e6\u05d5\u05e8\u05ea Tasmota.", "title": "Tasmota" }, diff --git a/homeassistant/components/tasmota/translations/hu.json b/homeassistant/components/tasmota/translations/hu.json index 4461f2a2b71..72a26925bc9 100644 --- a/homeassistant/components/tasmota/translations/hu.json +++ b/homeassistant/components/tasmota/translations/hu.json @@ -3,8 +3,14 @@ "abort": { "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." }, + "error": { + "invalid_discovery_topic": "\u00c9rv\u00e9nytelen felfedez\u00e9si t\u00e9ma el\u0151tag." + }, "step": { "config": { + "data": { + "discovery_prefix": "Felder\u00edt\u00e9si t\u00e9ma el\u0151tagja" + }, "description": "Add meg a Tasmota konfigur\u00e1ci\u00f3t.", "title": "Tasmota" }, diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py index 5c439651ed5..6732014c747 100644 --- a/homeassistant/components/ted5000/sensor.py +++ b/homeassistant/components/ted5000/sensor.py @@ -12,7 +12,13 @@ from homeassistant.components.sensor import ( STATE_CLASS_MEASUREMENT, SensorEntity, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, POWER_WATT, VOLT +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + ELECTRIC_POTENTIAL_VOLT, + POWER_WATT, +) from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle @@ -47,7 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for mtu in gateway.data: dev.append(Ted5000Sensor(gateway, name, mtu, POWER_WATT)) - dev.append(Ted5000Sensor(gateway, name, mtu, VOLT)) + dev.append(Ted5000Sensor(gateway, name, mtu, ELECTRIC_POTENTIAL_VOLT)) add_entities(dev) return True @@ -60,7 +66,7 @@ class Ted5000Sensor(SensorEntity): def __init__(self, gateway, name, mtu, unit): """Initialize the sensor.""" - units = {POWER_WATT: "power", VOLT: "voltage"} + units = {POWER_WATT: "power", ELECTRIC_POTENTIAL_VOLT: "voltage"} self._gateway = gateway self._name = f"{name} mtu{mtu} {units[unit]}" self._mtu = mtu @@ -112,4 +118,7 @@ class Ted5000Gateway: power = int(doc["LiveData"]["Power"]["MTU%d" % mtu]["PowerNow"]) voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu]["VoltageNow"]) - self.data[mtu] = {POWER_WATT: power, VOLT: voltage / 10} + self.data[mtu] = { + POWER_WATT: power, + ELECTRIC_POTENTIAL_VOLT: voltage / 10, + } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 4231bcc46af..02629e695fc 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -332,7 +332,7 @@ async def async_setup(hass, config): attribute_templ = data.get(attribute) if attribute_templ: if any( - isinstance(attribute_templ, vtype) for vtype in [float, int, str] + isinstance(attribute_templ, vtype) for vtype in (float, int, str) ): data[attribute] = attribute_templ else: @@ -352,7 +352,7 @@ async def async_setup(hass, config): msgtype = service.service kwargs = dict(service.data) - for attribute in [ + for attribute in ( ATTR_MESSAGE, ATTR_TITLE, ATTR_URL, @@ -360,7 +360,7 @@ async def async_setup(hass, config): ATTR_CAPTION, ATTR_LONGITUDE, ATTR_LATITUDE, - ]: + ): _render_template_attr(kwargs, attribute) _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs) @@ -848,7 +848,7 @@ class BaseTelegramBotEntity: if ( msg_data["from"].get("id") not in self.allowed_chat_ids - and msg_data["chat"].get("id") not in self.allowed_chat_ids + and msg_data["message"]["chat"].get("id") not in self.allowed_chat_ids ): # Neither from id nor chat id was in allowed_chat_ids, # origin is not allowed. diff --git a/homeassistant/components/tellduslive/translations/de.json b/homeassistant/components/tellduslive/translations/de.json index 0a952ba013b..adb5f0e2542 100644 --- a/homeassistant/components/tellduslive/translations/de.json +++ b/homeassistant/components/tellduslive/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dienst ist bereits konfiguriert", + "already_configured": "Der Dienst ist bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "unknown": "Unerwarteter Fehler", "unknown_authorize_url_generation": "Beim Generieren einer Authentifizierungs-URL ist ein unbekannter Fehler aufgetreten" diff --git a/homeassistant/components/tellduslive/translations/hu.json b/homeassistant/components/tellduslive/translations/hu.json index a496f1f2e45..207e9ada090 100644 --- a/homeassistant/components/tellduslive/translations/hu.json +++ b/homeassistant/components/tellduslive/translations/hu.json @@ -10,10 +10,15 @@ "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, "step": { + "auth": { + "description": "A TelldusLive-fi\u00f3k \u00f6sszekapcsol\u00e1sa:\n 1. Kattintson az al\u00e1bbi linkre\n 2. Jelentkezzen be a Telldus Live szolg\u00e1ltat\u00e1sba\n 3. Enged\u00e9lyezze ** {app_name} ** (kattintson a ** Yes ** gombra).\n 4. J\u00f6jj\u00f6n vissza ide, \u00e9s kattintson a ** SUBMIT ** gombra. \n\n [Link TelldusLive-fi\u00f3k] ( {auth_url} )", + "title": "Hiteles\u00edtsen a TelldusLive-on" + }, "user": { "data": { "host": "Hoszt" }, + "description": "\u00dcres", "title": "V\u00e1lassz v\u00e9gpontot." } } diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index f58c5916bfb..599c19388d6 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -11,6 +11,8 @@ from homeassistant.const import ( CONF_ID, CONF_NAME, CONF_PROTOCOL, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, PERCENTAGE, TEMP_CELSIUS, ) @@ -18,7 +20,9 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -DatatypeDescription = namedtuple("DatatypeDescription", ["name", "unit"]) +DatatypeDescription = namedtuple( + "DatatypeDescription", ["name", "unit", "device_class"] +) CONF_DATATYPE_MASK = "datatype_mask" CONF_ONLY_NAMED = "only_named" @@ -58,20 +62,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensor_value_descriptions = { tellcore_constants.TELLSTICK_TEMPERATURE: DatatypeDescription( - "temperature", config.get(CONF_TEMPERATURE_SCALE) + "temperature", config.get(CONF_TEMPERATURE_SCALE), DEVICE_CLASS_TEMPERATURE ), tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription( - "humidity", PERCENTAGE + "humidity", + PERCENTAGE, + DEVICE_CLASS_HUMIDITY, + ), + tellcore_constants.TELLSTICK_RAINRATE: DatatypeDescription( + "rain rate", "", None + ), + tellcore_constants.TELLSTICK_RAINTOTAL: DatatypeDescription( + "rain total", "", None ), - tellcore_constants.TELLSTICK_RAINRATE: DatatypeDescription("rain rate", ""), - tellcore_constants.TELLSTICK_RAINTOTAL: DatatypeDescription("rain total", ""), tellcore_constants.TELLSTICK_WINDDIRECTION: DatatypeDescription( - "wind direction", "" + "wind direction", "", None ), tellcore_constants.TELLSTICK_WINDAVERAGE: DatatypeDescription( - "wind average", "" + "wind average", "", None + ), + tellcore_constants.TELLSTICK_WINDGUST: DatatypeDescription( + "wind gust", "", None ), - tellcore_constants.TELLSTICK_WINDGUST: DatatypeDescription("wind gust", ""), } try: @@ -115,9 +127,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: continue - for datatype in sensor_value_descriptions: + for datatype, sensor_info in sensor_value_descriptions.items(): if datatype & datatype_mask and tellcore_sensor.has_value(datatype): - sensor_info = sensor_value_descriptions[datatype] sensors.append( TellstickSensor(sensor_name, tellcore_sensor, datatype, sensor_info) ) diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index 7edbd3ba812..7d447d3f9ea 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( CONF_NAME, CONF_OFFSET, + DEVICE_CLASS_TEMPERATURE, DEVICE_DEFAULT_NAME, TEMP_FAHRENHEIT, ) @@ -68,6 +69,7 @@ class TemperSensor(SensorEntity): self.current_value = None self._name = name self.set_temper_device(temper_device) + self._attr_device_class = DEVICE_CLASS_TEMPERATURE @property def name(self): diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index d985473792e..a9f28d56669 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -62,8 +62,7 @@ POSITION_ACTION = "set_cover_position" TILT_ACTION = "set_cover_tilt_position" CONF_TILT_OPTIMISTIC = "tilt_optimistic" -CONF_VALUE_OR_POSITION_TEMPLATE = "value_or_position" -CONF_OPEN_OR_CLOSE = "open_or_close" +CONF_OPEN_AND_CLOSE = "open_or_close" TILT_FEATURES = ( SUPPORT_OPEN_TILT @@ -76,15 +75,10 @@ COVER_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { - vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(OPEN_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Exclusive( - CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, - vol.Exclusive( - CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, vol.Optional(CONF_POSITION_TEMPLATE): cv.template, vol.Optional(CONF_TILT_TEMPLATE): cv.template, @@ -258,10 +252,11 @@ class CoverTemplate(TemplateEntity, CoverEntity): state = str(result).lower() if state in _VALID_STATES: - if state in ("true", STATE_OPEN): - self._position = 100 - else: - self._position = 0 + if not self._position_template: + if state in ("true", STATE_OPEN): + self._position = 100 + else: + self._position = 0 self._is_opening = state == STATE_OPENING self._is_closing = state == STATE_CLOSING @@ -271,7 +266,8 @@ class CoverTemplate(TemplateEntity, CoverEntity): state, ", ".join(_VALID_STATES), ) - self._position = None + if not self._position_template: + self._position = None @callback def _update_position(self, result): diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index c4a3977a4db..51431d133f7 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,7 +1,13 @@ """Support for locks which integrates with other components.""" import voluptuous as vol -from homeassistant.components.lock import PLATFORM_SCHEMA, LockEntity +from homeassistant.components.lock import ( + PLATFORM_SCHEMA, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, + LockEntity, +) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -9,6 +15,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_LOCKED, STATE_ON, + STATE_UNLOCKED, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -105,7 +112,22 @@ class TemplateLock(TemplateEntity, LockEntity): @property def is_locked(self): """Return true if lock is locked.""" - return self._state + return self._state in ("true", STATE_ON, STATE_LOCKED) + + @property + def is_jammed(self): + """Return true if lock is jammed.""" + return self._state == STATE_JAMMED + + @property + def is_unlocking(self): + """Return true if lock is unlocking.""" + return self._state == STATE_UNLOCKING + + @property + def is_locking(self): + """Return true if lock is locking.""" + return self._state == STATE_LOCKING @callback def _update_state(self, result): @@ -115,14 +137,14 @@ class TemplateLock(TemplateEntity, LockEntity): return if isinstance(result, bool): - self._state = result + self._state = STATE_LOCKED if result else STATE_UNLOCKED return if isinstance(result, str): - self._state = result.lower() in ("true", STATE_ON, STATE_LOCKED) + self._state = result.lower() return - self._state = False + self._state = None async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json index fe9edb21ea1..785088d2645 100644 --- a/homeassistant/components/template/manifest.json +++ b/homeassistant/components/template/manifest.json @@ -2,7 +2,7 @@ "domain": "template", "name": "Template", "documentation": "https://www.home-assistant.io/integrations/template", - "codeowners": ["@PhracturedBlue", "@tetienne"], + "codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], "quality_scale": "internal", "after_dependencies": ["group"], "iot_class": "local_push" diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 7bf6d6109be..6bf889ebf02 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -112,6 +112,9 @@ class _TemplateAttribute: class TemplateEntity(Entity): """Entity that uses templates to calculate attributes.""" + _attr_available = True + _attr_entity_picture = None + _attr_icon = None _attr_should_poll = False def __init__( @@ -128,7 +131,6 @@ class TemplateEntity(Entity): self._attribute_templates = attribute_templates self._attr_extra_state_attributes = {} self._availability_template = availability_template - self._attr_available = True self._icon_template = icon_template self._entity_picture_template = entity_picture_template self._self_ref_update_count = 0 diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 1b161b4aec8..b3162a19364 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.3.0", "tf-models-official==2.3.0", "pycocotools==2.0.1", - "numpy==1.20.3", + "numpy==1.21.1", "pillow==8.2.0" ], "codeowners": [], diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py index af2fd7ae769..46bc49b126b 100644 --- a/homeassistant/components/tesla/config_flow.py +++ b/homeassistant/components/tesla/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.helpers.httpx_client import SERVER_SOFTWARE, USER_AGENT from .const import ( CONF_EXPIRATION, + CONF_MFA, CONF_WAKE_ON_START, DEFAULT_SCAN_INTERVAL, DEFAULT_WAKE_ON_START, @@ -99,6 +100,7 @@ class TeslaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): { vol.Required(CONF_USERNAME, default=self.username): str, vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_MFA): str, } ) @@ -158,7 +160,9 @@ async def validate_input(hass: core.HomeAssistant, data): password=data[CONF_PASSWORD], update_interval=DEFAULT_SCAN_INTERVAL, ) - result = await controller.connect(test_login=True) + result = await controller.connect( + test_login=True, mfa_code=(data[CONF_MFA] if CONF_MFA in data else "") + ) config[CONF_TOKEN] = result["refresh_token"] config[CONF_ACCESS_TOKEN] = result["access_token"] config[CONF_EXPIRATION] = result[CONF_EXPIRATION] diff --git a/homeassistant/components/tesla/const.py b/homeassistant/components/tesla/const.py index 4155942c0ad..c288b3c1cda 100644 --- a/homeassistant/components/tesla/const.py +++ b/homeassistant/components/tesla/const.py @@ -1,6 +1,7 @@ """Const file for Tesla cars.""" CONF_EXPIRATION = "expiration" CONF_WAKE_ON_START = "enable_wake_on_start" +CONF_MFA = "mfa" DOMAIN = "tesla" DATA_LISTENER = "listener" DEFAULT_SCAN_INTERVAL = 660 diff --git a/homeassistant/components/tesla/strings.json b/homeassistant/components/tesla/strings.json index c75562528de..0f5a7666175 100644 --- a/homeassistant/components/tesla/strings.json +++ b/homeassistant/components/tesla/strings.json @@ -13,7 +13,8 @@ "user": { "data": { "username": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "mfa": "MFA Code (Optional)" }, "description": "Please enter your information.", "title": "Tesla - Configuration" diff --git a/homeassistant/components/tesla/translations/ca.json b/homeassistant/components/tesla/translations/ca.json index 2a51c0297ae..f5c0117f6a0 100644 --- a/homeassistant/components/tesla/translations/ca.json +++ b/homeassistant/components/tesla/translations/ca.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Codi MFA (opcional)", "password": "Contrasenya", "username": "Correu electr\u00f2nic" }, diff --git a/homeassistant/components/tesla/translations/de.json b/homeassistant/components/tesla/translations/de.json index 2fd964fe013..09934369f6b 100644 --- a/homeassistant/components/tesla/translations/de.json +++ b/homeassistant/components/tesla/translations/de.json @@ -12,8 +12,9 @@ "step": { "user": { "data": { + "mfa": "MFA-Code (optional)", "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "description": "Bitte gib deine Daten ein.", "title": "Tesla - Konfiguration" diff --git a/homeassistant/components/tesla/translations/en.json b/homeassistant/components/tesla/translations/en.json index 53b213ac19b..16a1c185138 100644 --- a/homeassistant/components/tesla/translations/en.json +++ b/homeassistant/components/tesla/translations/en.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA Code (Optional)", "password": "Password", "username": "Email" }, diff --git a/homeassistant/components/tesla/translations/et.json b/homeassistant/components/tesla/translations/et.json index c7ceae36990..ab36a4e503d 100644 --- a/homeassistant/components/tesla/translations/et.json +++ b/homeassistant/components/tesla/translations/et.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA kood (valikuline)", "password": "Salas\u00f5na", "username": "E-post" }, diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json index 889c32a7d91..174b687f26f 100644 --- a/homeassistant/components/tesla/translations/fr.json +++ b/homeassistant/components/tesla/translations/fr.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Code MFA (facultatif)", "password": "Mot de passe", "username": "Email" }, diff --git a/homeassistant/components/tesla/translations/it.json b/homeassistant/components/tesla/translations/it.json index 3a137da78f1..05a663df149 100644 --- a/homeassistant/components/tesla/translations/it.json +++ b/homeassistant/components/tesla/translations/it.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Codice autenticazione a pi\u00f9 fattori MFA (facoltativo)", "password": "Password", "username": "E-mail" }, diff --git a/homeassistant/components/tesla/translations/nl.json b/homeassistant/components/tesla/translations/nl.json index 5655a641f96..689766cd906 100644 --- a/homeassistant/components/tesla/translations/nl.json +++ b/homeassistant/components/tesla/translations/nl.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA Code (optioneel)", "password": "Wachtwoord", "username": "E-mail" }, diff --git a/homeassistant/components/tesla/translations/pl.json b/homeassistant/components/tesla/translations/pl.json index 7ec634cd56c..266a0e82dbe 100644 --- a/homeassistant/components/tesla/translations/pl.json +++ b/homeassistant/components/tesla/translations/pl.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "Kod uwierzytelniania wielosk\u0142adnikowego (opcjonalnie)", "password": "Has\u0142o", "username": "Adres e-mail" }, diff --git a/homeassistant/components/tesla/translations/ru.json b/homeassistant/components/tesla/translations/ru.json index d62a2e1f168..191d10b8bea 100644 --- a/homeassistant/components/tesla/translations/ru.json +++ b/homeassistant/components/tesla/translations/ru.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "\u041a\u043e\u0434 MFA (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" }, diff --git a/homeassistant/components/tesla/translations/zh-Hant.json b/homeassistant/components/tesla/translations/zh-Hant.json index d9b7fd4ef79..9ff407efaa3 100644 --- a/homeassistant/components/tesla/translations/zh-Hant.json +++ b/homeassistant/components/tesla/translations/zh-Hant.json @@ -12,6 +12,7 @@ "step": { "user": { "data": { + "mfa": "MFA \u78bc\uff08\u9078\u9805\uff09", "password": "\u5bc6\u78bc", "username": "\u96fb\u5b50\u90f5\u4ef6" }, diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index 86427349f31..1bdbbc5fcc3 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_EXCLUDE, CONF_MONITORED_CONDITIONS, CONF_PASSWORD, + DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT, ) import homeassistant.helpers.config_validation as cv @@ -105,6 +106,7 @@ class ThermoworksSmokeSensor(SensorEntity): self._unique_id = f"{serial}-{sensor_type}" self.serial = serial self.mgr = mgr + self._attr_device_class = DEVICE_CLASS_TEMPERATURE self.update_unit() @property diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index d575a520cb2..a18bb855f8f 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -4,10 +4,8 @@ import logging import aiohttp import tibber -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -20,13 +18,7 @@ PLATFORMS = [ "sensor", ] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.deprecated(DOMAIN) _LOGGER = logging.getLogger(__name__) @@ -35,18 +27,6 @@ async def async_setup(hass, config): """Set up the Tibber component.""" hass.data[DATA_HASS_CONFIG] = config - - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - return True @@ -82,7 +62,7 @@ async def async_setup_entry(hass, entry): # have to use discovery to load platform. hass.async_create_task( discovery.async_load_platform( - hass, "notify", DOMAIN, {CONF_NAME: DOMAIN}, hass.data[DATA_HASS_CONFIG] + hass, "notify", DOMAIN, {}, hass.data[DATA_HASS_CONFIG] ) ) return True diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py index 1c1cef88776..4e804225c56 100644 --- a/homeassistant/components/tibber/config_flow.py +++ b/homeassistant/components/tibber/config_flow.py @@ -19,10 +19,6 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_info): - """Set the config entry up from yaml.""" - return await self.async_step_user(import_info) - async def async_step_user(self, user_input=None): """Handle the initial step.""" diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index a915db8a665..20b62832619 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.18.0"], + "requirements": ["pyTibber==0.19.0"], "codeowners": ["@danielhiversen"], "quality_scale": "silver", "config_flow": true, diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index bf6218dcb31..b5012cdc41d 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -1,6 +1,10 @@ """Support for Tibber sensors.""" +from __future__ import annotations + import asyncio +from dataclasses import dataclass from datetime import timedelta +from enum import Enum import logging from random import randrange @@ -16,14 +20,15 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_VOLTAGE, STATE_CLASS_MEASUREMENT, SensorEntity, + SensorEntityDescription, ) from homeassistant.const import ( - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, SIGNAL_STRENGTH_DECIBELS, - VOLT, ) from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady @@ -45,102 +50,171 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) PARALLEL_UPDATES = 0 SIGNAL_UPDATE_ENTITY = "tibber_rt_update_{}" -RT_SENSOR_MAP = { - "averagePower": ["average power", DEVICE_CLASS_POWER, POWER_WATT, None], - "power": ["power", DEVICE_CLASS_POWER, POWER_WATT, None], - "powerProduction": ["power production", DEVICE_CLASS_POWER, POWER_WATT, None], - "minPower": ["min power", DEVICE_CLASS_POWER, POWER_WATT, None], - "maxPower": ["max power", DEVICE_CLASS_POWER, POWER_WATT, None], - "accumulatedConsumption": [ - "accumulated consumption", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "accumulatedConsumptionLastHour": [ - "accumulated consumption current hour", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "accumulatedProduction": [ - "accumulated production", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "accumulatedProductionLastHour": [ - "accumulated production current hour", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "lastMeterConsumption": [ - "last meter consumption", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "lastMeterProduction": [ - "last meter production", - DEVICE_CLASS_ENERGY, - ENERGY_KILO_WATT_HOUR, - STATE_CLASS_MEASUREMENT, - ], - "voltagePhase1": [ - "voltage phase1", - DEVICE_CLASS_VOLTAGE, - VOLT, - STATE_CLASS_MEASUREMENT, - ], - "voltagePhase2": [ - "voltage phase2", - DEVICE_CLASS_VOLTAGE, - VOLT, - STATE_CLASS_MEASUREMENT, - ], - "voltagePhase3": [ - "voltage phase3", - DEVICE_CLASS_VOLTAGE, - VOLT, - STATE_CLASS_MEASUREMENT, - ], - "currentL1": [ - "current L1", - DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, - ], - "currentL2": [ - "current L2", - DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, - ], - "currentL3": [ - "current L3", - DEVICE_CLASS_CURRENT, - ELECTRICAL_CURRENT_AMPERE, - STATE_CLASS_MEASUREMENT, - ], - "signalStrength": [ - "signal strength", - DEVICE_CLASS_SIGNAL_STRENGTH, - SIGNAL_STRENGTH_DECIBELS, - STATE_CLASS_MEASUREMENT, - ], - "accumulatedCost": [ - "accumulated cost", - DEVICE_CLASS_MONETARY, - None, - STATE_CLASS_MEASUREMENT, - ], - "powerFactor": [ - "power factor", - DEVICE_CLASS_POWER_FACTOR, - PERCENTAGE, - STATE_CLASS_MEASUREMENT, - ], + +class ResetType(Enum): + """Data reset type.""" + + HOURLY = "hourly" + DAILY = "daily" + NEVER = "never" + + +@dataclass +class TibberSensorEntityDescription(SensorEntityDescription): + """Describes Tibber sensor entity.""" + + reset_type: ResetType | None = None + + +RT_SENSOR_MAP: dict[str, TibberSensorEntityDescription] = { + "averagePower": TibberSensorEntityDescription( + key="averagePower", + name="average power", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_WATT, + ), + "power": TibberSensorEntityDescription( + key="power", + name="power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ), + "powerProduction": TibberSensorEntityDescription( + key="powerProduction", + name="power production", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ), + "minPower": TibberSensorEntityDescription( + key="minPower", + name="min power", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_WATT, + ), + "maxPower": TibberSensorEntityDescription( + key="maxPower", + name="max power", + device_class=DEVICE_CLASS_POWER, + unit_of_measurement=POWER_WATT, + ), + "accumulatedConsumption": TibberSensorEntityDescription( + key="accumulatedConsumption", + name="accumulated consumption", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, + ), + "accumulatedConsumptionLastHour": TibberSensorEntityDescription( + key="accumulatedConsumptionLastHour", + name="accumulated consumption current hour", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.HOURLY, + ), + "accumulatedProduction": TibberSensorEntityDescription( + key="accumulatedProduction", + name="accumulated production", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, + ), + "accumulatedProductionLastHour": TibberSensorEntityDescription( + key="accumulatedProductionLastHour", + name="accumulated production current hour", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.HOURLY, + ), + "lastMeterConsumption": TibberSensorEntityDescription( + key="lastMeterConsumption", + name="last meter consumption", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + "lastMeterProduction": TibberSensorEntityDescription( + key="lastMeterProduction", + name="last meter production", + device_class=DEVICE_CLASS_ENERGY, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=STATE_CLASS_MEASUREMENT, + ), + "voltagePhase1": TibberSensorEntityDescription( + key="voltagePhase1", + name="voltage phase1", + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "voltagePhase2": TibberSensorEntityDescription( + key="voltagePhase2", + name="voltage phase2", + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "voltagePhase3": TibberSensorEntityDescription( + key="voltagePhase3", + name="voltage phase3", + device_class=DEVICE_CLASS_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=STATE_CLASS_MEASUREMENT, + ), + "currentL1": TibberSensorEntityDescription( + key="currentL1", + name="current L1", + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "currentL2": TibberSensorEntityDescription( + key="currentL2", + name="current L2", + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "currentL3": TibberSensorEntityDescription( + key="currentL3", + name="current L3", + device_class=DEVICE_CLASS_CURRENT, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + state_class=STATE_CLASS_MEASUREMENT, + ), + "signalStrength": TibberSensorEntityDescription( + key="signalStrength", + name="signal strength", + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=STATE_CLASS_MEASUREMENT, + ), + "accumulatedReward": TibberSensorEntityDescription( + key="accumulatedReward", + name="accumulated reward", + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, + ), + "accumulatedCost": TibberSensorEntityDescription( + key="accumulatedCost", + name="accumulated cost", + device_class=DEVICE_CLASS_MONETARY, + state_class=STATE_CLASS_MEASUREMENT, + reset_type=ResetType.DAILY, + ), + "powerFactor": TibberSensorEntityDescription( + key="powerFactor", + name="power factor", + device_class=DEVICE_CLASS_POWER_FACTOR, + unit_of_measurement=PERCENTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), } @@ -305,39 +379,33 @@ class TibberSensorRT(TibberSensor): """Representation of a Tibber sensor for real time consumption.""" _attr_should_poll = False + entity_description: TibberSensorEntityDescription def __init__( - self, tibber_home, sensor_name, device_class, unit, initial_state, state_class + self, + tibber_home, + description: TibberSensorEntityDescription, + initial_state, ): """Initialize the sensor.""" super().__init__(tibber_home) - self._sensor_name = sensor_name + self.entity_description = description self._model = "Tibber Pulse" self._device_name = f"{self._model} {self._home_name}" - self._attr_device_class = device_class - self._attr_name = f"{self._sensor_name} {self._home_name}" + self._attr_name = f"{description.name} {self._home_name}" self._attr_state = initial_state - self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{self._sensor_name}" - self._attr_unit_of_measurement = unit - self._attr_state_class = state_class - if sensor_name in [ - "last meter consumption", - "last meter production", - ]: + self._attr_unique_id = f"{self._tibber_home.home_id}_rt_{description.name}" + + if description.name in ("accumulated cost", "accumulated reward"): + self._attr_unit_of_measurement = tibber_home.currency + if description.reset_type == ResetType.NEVER: self._attr_last_reset = dt_util.utc_from_timestamp(0) - elif self._sensor_name in [ - "accumulated consumption", - "accumulated production", - "accumulated cost", - ]: + elif description.reset_type == ResetType.DAILY: self._attr_last_reset = dt_util.as_utc( dt_util.now().replace(hour=0, minute=0, second=0, microsecond=0) ) - elif self._sensor_name in [ - "accumulated consumption current hour", - "accumulated production current hour", - ]: + elif description.reset_type == ResetType.HOURLY: self._attr_last_reset = dt_util.as_utc( dt_util.now().replace(minute=0, second=0, microsecond=0) ) @@ -362,18 +430,17 @@ class TibberSensorRT(TibberSensor): @callback def _set_state(self, state, timestamp): """Set sensor state.""" - if state < self._attr_state and self._sensor_name in [ - "accumulated consumption", - "accumulated production", - "accumulated cost", - ]: + if ( + state < self._attr_state + and self.entity_description.reset_type == ResetType.DAILY + ): self._attr_last_reset = dt_util.as_utc( timestamp.replace(hour=0, minute=0, second=0, microsecond=0) ) - if state < self._attr_state and self._sensor_name in [ - "accumulated consumption current hour", - "accumulated production current hour", - ]: + if ( + state < self._attr_state + and self.entity_description.reset_type == ResetType.HOURLY + ): self._attr_last_reset = dt_util.as_utc( timestamp.replace(minute=0, second=0, microsecond=0) ) @@ -419,18 +486,10 @@ class TibberRtDataHandler: timestamp, ) else: - sensor_name, device_class, unit, state_class = RT_SENSOR_MAP[ - sensor_type - ] - if sensor_type == "accumulatedCost": - unit = self._tibber_home.currency entity = TibberSensorRT( self._tibber_home, - sensor_name, - device_class, - unit, + RT_SENSOR_MAP[sensor_type], state, - state_class, ) new_entities.append(entity) self._entities[sensor_type] = entity.unique_id diff --git a/homeassistant/components/tibber/translations/de.json b/homeassistant/components/tibber/translations/de.json index 8d49c9d9e61..f3f722ae835 100644 --- a/homeassistant/components/tibber/translations/de.json +++ b/homeassistant/components/tibber/translations/de.json @@ -11,9 +11,9 @@ "step": { "user": { "data": { - "access_token": "Zugriffs-Token" + "access_token": "Zugangstoken" }, - "description": "Geben Sie Ihr Zugangsk\u00fcrzel von https://developer.tibber.com/settings/accesstoken ein.", + "description": "Gib dein Zugangsk\u00fcrzel von https://developer.tibber.com/settings/accesstoken ein.", "title": "Tibber" } } diff --git a/homeassistant/components/tibber/translations/he.json b/homeassistant/components/tibber/translations/he.json index 780c7990217..3deeeef2e9c 100644 --- a/homeassistant/components/tibber/translations/he.json +++ b/homeassistant/components/tibber/translations/he.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u05d7\u05e9\u05d1\u05d5\u05df Tibber \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" + "already_configured": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05d6\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8" }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 7faefe4d275..5b52e637c64 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -1,15 +1,19 @@ """The Tile component.""" +from __future__ import annotations + from datetime import timedelta from functools import partial from pytile import async_login from pytile.errors import InvalidAuthError, SessionExpiredError, TileError +from pytile.tile import Tile +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.entity_registry import async_migrate_entries +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util.async_ import gather_with_concurrency @@ -24,19 +28,14 @@ DEFAULT_UPDATE_INTERVAL = timedelta(minutes=2) CONF_SHOW_INACTIVE = "show_inactive" -async def async_setup(hass, config): - """Set up the Tile component.""" - hass.data[DOMAIN] = {DATA_COORDINATOR: {}, DATA_TILE: {}} - return True - - -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Tile as config entry.""" + hass.data.setdefault(DOMAIN, {DATA_COORDINATOR: {}, DATA_TILE: {}}) hass.data[DOMAIN][DATA_COORDINATOR][entry.entry_id] = {} hass.data[DOMAIN][DATA_TILE][entry.entry_id] = {} @callback - def async_migrate_callback(entity_entry): + def async_migrate_callback(entity_entry: RegistryEntry) -> dict | None: """ Define a callback to migrate appropriate Tile entities to new unique IDs. @@ -44,7 +43,7 @@ async def async_setup_entry(hass, entry): New: {username}_{uuid} """ if entity_entry.unique_id.startswith(entry.data[CONF_USERNAME]): - return + return None new_unique_id = f"{entry.data[CONF_USERNAME]}_".join( entity_entry.unique_id.split(f"{DOMAIN}_") @@ -76,10 +75,10 @@ async def async_setup_entry(hass, entry): except TileError as err: raise ConfigEntryNotReady("Error during integration setup") from err - async def async_update_tile(tile): + async def async_update_tile(tile: Tile) -> None: """Update the Tile.""" try: - return await tile.async_update() + await tile.async_update() except SessionExpiredError: LOGGER.info("Tile session expired; creating a new one") await client.async_init() @@ -106,7 +105,7 @@ async def async_setup_entry(hass, entry): return True -async def async_unload_entry(hass, entry): +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a Tile config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index 23bf4ffa79b..3c78e5d2bca 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -1,10 +1,15 @@ """Config flow to configure the Tile integration.""" +from __future__ import annotations + +from typing import Any + from pytile import async_login from pytile.errors import TileError import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -15,23 +20,25 @@ class TileFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" self.data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) - async def _show_form(self, errors=None): + async def _show_form(self, errors: dict[str, Any] | None = None) -> FlowResult: """Show the form to the user.""" return self.async_show_form( step_id="user", data_schema=self.data_schema, errors=errors or {} ) - async def async_step_import(self, import_config): + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the start of the config flow.""" if not user_input: return await self._show_form() diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py index add6e5f94a0..27446389f50 100644 --- a/homeassistant/components/tile/device_tracker.py +++ b/homeassistant/components/tile/device_tracker.py @@ -1,12 +1,23 @@ """Support for Tile device trackers.""" +from __future__ import annotations + +from collections.abc import Awaitable import logging +from typing import Any, Callable + +from pytile.tile import Tile from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from . import DATA_COORDINATOR, DATA_TILE, DOMAIN @@ -25,7 +36,9 @@ DEFAULT_ATTRIBUTION = "Data provided by Tile" DEFAULT_ICON = "mdi:view-grid" -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: """Set up Tile device trackers.""" async_add_entities( [ @@ -39,7 +52,12 @@ async def async_setup_entry(hass, entry, async_add_entities): ) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: Callable[..., Awaitable[None]], + discovery_info: dict[str, Any] | None = None, +) -> bool: """Detect a legacy configuration and import it.""" hass.async_create_task( hass.config_entries.flow.async_init( @@ -63,79 +81,64 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): class TileDeviceTracker(CoordinatorEntity, TrackerEntity): """Representation of a network infrastructure device.""" - def __init__(self, entry, coordinator, tile): + _attr_icon = DEFAULT_ICON + + def __init__( + self, entry: ConfigEntry, coordinator: DataUpdateCoordinator, tile: Tile + ) -> None: """Initialize.""" super().__init__(coordinator) - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._attr_name = tile.name + self._attr_unique_id = f"{entry.data[CONF_USERNAME]}_{tile.uuid}" self._entry = entry self._tile = tile @property - def available(self): + def available(self) -> bool: """Return if entity is available.""" - return self.coordinator.last_update_success and not self._tile.dead + return super().available and not self._tile.dead @property - def battery_level(self): - """Return the battery level of the device. - - Percentage from 0-100. - """ - return None - - @property - def extra_state_attributes(self): - """Return the device state attributes.""" - return self._attrs - - @property - def icon(self): - """Return the icon.""" - return DEFAULT_ICON - - @property - def location_accuracy(self): + def location_accuracy(self) -> int: """Return the location accuracy of the device. Value in meters. """ - return self._tile.accuracy + if not self._tile.accuracy: + return super().location_accuracy + return int(self._tile.accuracy) @property - def latitude(self) -> float: + def latitude(self) -> float | None: """Return latitude value of the device.""" + if not self._tile.latitude: + return None return self._tile.latitude @property - def longitude(self) -> float: + def longitude(self) -> float | None: """Return longitude value of the device.""" + if not self._tile.longitude: + return None return self._tile.longitude @property - def name(self): - """Return the name.""" - return self._tile.name - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"{self._entry.data[CONF_USERNAME]}_{self._tile.uuid}" - - @property - def source_type(self): + def source_type(self) -> str: """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Respond to a DataUpdateCoordinator update.""" self._update_from_latest_data() self.async_write_ha_state() @callback - def _update_from_latest_data(self): + def _update_from_latest_data(self) -> None: """Update the entity from the latest data.""" - self._attrs.update( + self._attr_extra_state_attributes.update( { ATTR_ALTITUDE: self._tile.altitude, ATTR_IS_LOST: self._tile.lost, @@ -145,7 +148,7 @@ class TileDeviceTracker(CoordinatorEntity, TrackerEntity): } ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() self._update_from_latest_data() diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json index e8d386f4a88..39295eed646 100644 --- a/homeassistant/components/tile/manifest.json +++ b/homeassistant/components/tile/manifest.json @@ -3,7 +3,7 @@ "name": "Tile", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tile", - "requirements": ["pytile==5.2.2"], + "requirements": ["pytile==5.2.3"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/tile/translations/de.json b/homeassistant/components/tile/translations/de.json index 1c2af82aa63..5866a1e0f5b 100644 --- a/homeassistant/components/tile/translations/de.json +++ b/homeassistant/components/tile/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Konto ist bereits konfiguriert" + "already_configured": "Konto wurde bereits konfiguriert" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung" @@ -10,7 +10,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail Adresse" + "username": "E-Mail" }, "title": "Kachel konfigurieren" } diff --git a/homeassistant/components/tile/translations/nl.json b/homeassistant/components/tile/translations/nl.json index 236d250122a..2f81919fc4b 100644 --- a/homeassistant/components/tile/translations/nl.json +++ b/homeassistant/components/tile/translations/nl.json @@ -12,7 +12,7 @@ "password": "Wachtwoord", "username": "E-mail" }, - "title": "Tegel configureren" + "title": "Configureer Tile" } } }, @@ -22,7 +22,7 @@ "data": { "show_inactive": "Toon inactieve tegels" }, - "title": "Tegel configureren" + "title": "Configureer Tile" } } } diff --git a/homeassistant/components/timer/translations/de.json b/homeassistant/components/timer/translations/de.json index 47cf5b15f23..ba24845aadb 100644 --- a/homeassistant/components/timer/translations/de.json +++ b/homeassistant/components/timer/translations/de.json @@ -2,7 +2,7 @@ "state": { "_": { "active": "Aktiv", - "idle": "Leerlauf", + "idle": "Unt\u00e4tig", "paused": "Pausiert" } } diff --git a/homeassistant/components/timer/translations/he.json b/homeassistant/components/timer/translations/he.json index 2203ca93e5b..14e21d4bfef 100644 --- a/homeassistant/components/timer/translations/he.json +++ b/homeassistant/components/timer/translations/he.json @@ -2,7 +2,7 @@ "state": { "_": { "active": "\u05e4\u05e2\u05d9\u05dc", - "idle": "\u05dc\u05d0 \u05e4\u05e2\u05d9\u05dc", + "idle": "\u05de\u05de\u05ea\u05d9\u05df", "paused": "\u05de\u05d5\u05e9\u05d4\u05d4" } } diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index de756225d57..9983dc4bee6 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -42,14 +42,14 @@ async def async_setup_entry( sensors.extend( [ ToonBoilerBinarySensor(coordinator, key=key) - for key in [ + for key in ( "thermostat_info_ot_communication_error_0", "thermostat_info_error_found_255", "thermostat_info_burner_info_None", "thermostat_info_burner_info_1", "thermostat_info_burner_info_2", "thermostat_info_burner_info_3", - ] + ) ] ) diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 90f74ceae87..b16672674af 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -89,7 +89,7 @@ async def async_setup_entry( sensors.extend( [ ToonSolarDeviceSensor(coordinator, key=key) - for key in [ + for key in ( "solar_value", "solar_maximum", "solar_produced", @@ -98,7 +98,7 @@ async def async_setup_entry( "power_usage_day_from_grid_usage", "power_usage_day_to_grid_usage", "power_usage_current_covered_by_solar", - ] + ) ] ) diff --git a/homeassistant/components/toon/translations/ar.json b/homeassistant/components/toon/translations/ar.json new file mode 100644 index 00000000000..be5ae6bed73 --- /dev/null +++ b/homeassistant/components/toon/translations/ar.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u062a\u0645 \u062a\u0643\u0648\u064a\u0646 \u0627\u0644\u0627\u062a\u0641\u0627\u0642\u064a\u0629 \u0627\u0644\u0645\u062d\u062f\u062f\u0629 \u0628\u0627\u0644\u0641\u0639\u0644.", + "no_agreements": "\u0644\u0627 \u064a\u062d\u062a\u0648\u064a \u0647\u0630\u0627 \u0627\u0644\u062d\u0633\u0627\u0628 \u0639\u0644\u0649 \u0639\u0631\u0648\u0636 Toon." + }, + "step": { + "agreement": { + "data": { + "agreement": "\u0627\u062a\u0641\u0627\u0642\u064a\u0629" + }, + "description": "\u062d\u062f\u062f \u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0627\u062a\u0641\u0627\u0642\u064a\u0629 \u0627\u0644\u0630\u064a \u062a\u0631\u064a\u062f \u0625\u0636\u0627\u0641\u062a\u0647.", + "title": "\u062d\u062f\u062f \u0627\u062a\u0641\u0627\u0642\u064a\u062a\u0643" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/translations/de.json b/homeassistant/components/toon/translations/de.json index c76bab5ef91..8f7531896f8 100644 --- a/homeassistant/components/toon/translations/de.json +++ b/homeassistant/components/toon/translations/de.json @@ -13,11 +13,11 @@ "data": { "agreement": "Vereinbarung" }, - "description": "W\u00e4hlen Sie die Vereinbarungsadresse aus, die du hinzuf\u00fcgen m\u00f6chtest.", + "description": "W\u00e4hle die Vereinbarungsadresse aus, die du hinzuf\u00fcgen m\u00f6chtest.", "title": "W\u00e4hle deine Vereinbarung" }, "pick_implementation": { - "title": "W\u00e4hlen Sie Ihren Mandanten f\u00fcr die Authentifizierung aus" + "title": "W\u00e4hle deinen Mandanten f\u00fcr die Authentifizierung aus" } } } diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json index cd832522870..6371bf4c6fd 100644 --- a/homeassistant/components/toon/translations/hu.json +++ b/homeassistant/components/toon/translations/hu.json @@ -3,6 +3,7 @@ "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rlek, k\u00f6vesd a dokument\u00e1ci\u00f3t.", + "no_agreements": "Ennek a fi\u00f3knak nincsenek Toon kijelz\u0151i.", "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3t [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lsz.", "unknown_authorize_url_generation": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n." } diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 156259adccb..8e3053d9bd8 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -95,10 +95,10 @@ class TorqueReceiveDataView(HomeAssistantView): if pid in self.sensors: self.sensors[pid].async_on_update(data[key]) - for pid in names: + for pid, name in names.items(): if pid not in self.sensors: self.sensors[pid] = TorqueSensor( - ENTITY_NAME_FORMAT.format(self.vehicle, names[pid]), units.get(pid) + ENTITY_NAME_FORMAT.format(self.vehicle, name), units.get(pid) ) hass.async_add_job(self.add_entities, [self.sensors[pid]]) diff --git a/homeassistant/components/totalconnect/translations/de.json b/homeassistant/components/totalconnect/translations/de.json index b6543752043..c435169c804 100644 --- a/homeassistant/components/totalconnect/translations/de.json +++ b/homeassistant/components/totalconnect/translations/de.json @@ -14,7 +14,7 @@ "location": "Standort", "usercode": "Benutzercode" }, - "description": "Geben Sie den Benutzercode f\u00fcr den Benutzer {location_id} an dieser Stelle ein", + "description": "Gib den Benutzercode f\u00fcr den Benutzer {location_id} an dieser Stelle ein", "title": "Standort-Benutzercodes" }, "reauth_confirm": { diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index b46bf127963..668b20726fc 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -11,7 +11,8 @@ "step": { "locations": { "data": { - "location": "Emplacement" + "location": "Emplacement", + "usercode": "Code d'utilisateur" }, "description": "Saisissez le code d'utilisateur de cet utilisateur \u00e0 cet emplacement", "title": "Codes d'utilisateur de l'emplacement" diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 6002f056635..e9e991d81d4 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -5,15 +5,20 @@ "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { - "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "usercode": "A felhaszn\u00e1l\u00f3i k\u00f3d nem \u00e9rv\u00e9nyes erre a felhaszn\u00e1l\u00f3ra ezen a helyen" }, "step": { "locations": { "data": { - "location": "Elhelyezked\u00e9s" - } + "location": "Elhelyezked\u00e9s", + "usercode": "Felhaszn\u00e1l\u00f3i k\u00f3d" + }, + "description": "Adja meg ennek a felhaszn\u00e1l\u00f3nak a felhaszn\u00e1l\u00f3i k\u00f3dj\u00e1t a k\u00f6vetkez\u0151 helyen: {location_id}", + "title": "Helyhaszn\u00e1lati k\u00f3dok" }, "reauth_confirm": { + "description": "A Total Connectnek \u00fajra kell hiteles\u00edtenie a fi\u00f3kj\u00e1t.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "user": { diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 1f843d364d8..552e5666db8 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,37 +1,60 @@ """Component to embed TP-Link smart home devices.""" -import logging +from __future__ import annotations +from datetime import datetime, timedelta +import logging +import time +from typing import Any + +from pyHS100.smartdevice import SmartDevice, SmartDeviceException +from pyHS100.smartplug import SmartPlug import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.components.switch import ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ALIAS, + CONF_DEVICE_ID, + CONF_HOST, + CONF_MAC, + CONF_STATE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import utc_from_timestamp -from .common import ( +from .common import SmartDevices, async_discover_devices, get_static_devices +from .const import ( ATTR_CONFIG, + ATTR_CURRENT_A, + ATTR_TOTAL_ENERGY_KWH, CONF_DIMMER, CONF_DISCOVERY, + CONF_EMETER_PARAMS, CONF_LIGHT, + CONF_MODEL, CONF_STRIP, + CONF_SW_VERSION, CONF_SWITCH, - SmartDevices, - async_discover_devices, - get_static_devices, + COORDINATORS, + PLATFORMS, + UNAVAILABLE_DEVICES, + UNAVAILABLE_RETRY_DELAY, ) _LOGGER = logging.getLogger(__name__) DOMAIN = "tplink" -PLATFORMS = [CONF_LIGHT, CONF_SWITCH] - TPLINK_HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): cv.string}) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -76,14 +99,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up TPLink from a config entry.""" config_data = hass.data[DOMAIN].get(ATTR_CONFIG) + if config_data is None and entry.data: + config_data = entry.data + elif config_data is not None: + hass.config_entries.async_update_entry(entry, data=config_data) device_registry = dr.async_get(hass) tplink_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) device_count = len(tplink_devices) + hass_data: dict[str, Any] = hass.data[DOMAIN] # These will contain the initialized devices - lights = hass.data[DOMAIN][CONF_LIGHT] = [] - switches = hass.data[DOMAIN][CONF_SWITCH] = [] + hass_data[CONF_LIGHT] = [] + hass_data[CONF_SWITCH] = [] + hass_data[UNAVAILABLE_DEVICES] = [] + lights: list[SmartDevice] = hass_data[CONF_LIGHT] + switches: list[SmartPlug] = hass_data[CONF_SWITCH] + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] # Add static devices static_devices = SmartDevices() @@ -102,14 +134,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: lights.extend(discovered_devices.lights) switches.extend(discovered_devices.switches) - forward_setup = hass.config_entries.async_forward_entry_setup if lights: _LOGGER.debug( "Got %s lights: %s", len(lights), ", ".join(d.host for d in lights) ) - hass.async_create_task(forward_setup(entry, "light")) - if switches: _LOGGER.debug( "Got %s switches: %s", @@ -117,16 +146,133 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ", ".join(d.host for d in switches), ) - hass.async_create_task(forward_setup(entry, "switch")) + async def async_retry_devices(self) -> None: + """Retry unavailable devices.""" + unavailable_devices: list[SmartDevice] = hass_data[UNAVAILABLE_DEVICES] + _LOGGER.debug( + "retry during setup unavailable devices: %s", + [d.host for d in unavailable_devices], + ) + + for device in unavailable_devices: + try: + device.get_sysinfo() + except SmartDeviceException: + continue + _LOGGER.debug( + "at least one device is available again, so reload integration" + ) + await hass.config_entries.async_reload(entry.entry_id) + break + + # prepare DataUpdateCoordinators + hass_data[COORDINATORS] = {} + for switch in switches: + + try: + await hass.async_add_executor_job(switch.get_sysinfo) + except SmartDeviceException: + _LOGGER.warning( + "Device at '%s' not reachable during setup, will retry later", + switch.host, + ) + unavailable_devices.append(switch) + continue + + hass_data[COORDINATORS][ + switch.context or switch.mac + ] = coordinator = SmartPlugDataUpdateCoordinator(hass, switch) + await coordinator.async_config_entry_first_refresh() + + if unavailable_devices: + entry.async_on_unload( + async_track_time_interval( + hass, async_retry_devices, UNAVAILABLE_RETRY_DELAY + ) + ) + unavailable_devices_hosts = [d.host for d in unavailable_devices] + hass_data[CONF_SWITCH] = [ + s for s in switches if s.host not in unavailable_devices_hosts + ] + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - platforms = [platform for platform in PLATFORMS if hass.data[DOMAIN].get(platform)] - unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + hass_data: dict[str, Any] = hass.data[DOMAIN] if unload_ok: - hass.data[DOMAIN].clear() + hass_data.clear() return unload_ok + + +class SmartPlugDataUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator to gather data for specific SmartPlug.""" + + def __init__( + self, + hass: HomeAssistant, + smartplug: SmartPlug, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.smartplug = smartplug + + update_interval = timedelta(seconds=30) + super().__init__( + hass, _LOGGER, name=smartplug.alias, update_interval=update_interval + ) + + async def _async_update_data(self) -> dict: + """Fetch all device and sensor data from api.""" + try: + info = self.smartplug.sys_info + data = { + CONF_HOST: self.smartplug.host, + CONF_MAC: info["mac"], + CONF_MODEL: info["model"], + CONF_SW_VERSION: info["sw_ver"], + } + if self.smartplug.context is None: + data[CONF_ALIAS] = info["alias"] + data[CONF_DEVICE_ID] = info["mac"] + data[CONF_STATE] = ( + self.smartplug.state == self.smartplug.SWITCH_STATE_ON + ) + else: + plug_from_context = next( + c + for c in self.smartplug.sys_info["children"] + if c["id"] == self.smartplug.context + ) + data[CONF_ALIAS] = plug_from_context["alias"] + data[CONF_DEVICE_ID] = self.smartplug.context + data[CONF_STATE] = plug_from_context["state"] == 1 + if self.smartplug.has_emeter: + emeter_readings = self.smartplug.get_emeter_realtime() + data[CONF_EMETER_PARAMS] = { + ATTR_CURRENT_POWER_W: round(float(emeter_readings["power"]), 2), + ATTR_TOTAL_ENERGY_KWH: round(float(emeter_readings["total"]), 3), + ATTR_VOLTAGE: round(float(emeter_readings["voltage"]), 1), + ATTR_CURRENT_A: round(float(emeter_readings["current"]), 2), + ATTR_LAST_RESET: {ATTR_TOTAL_ENERGY_KWH: utc_from_timestamp(0)}, + } + emeter_statics = self.smartplug.get_emeter_daily() + data[CONF_EMETER_PARAMS][ATTR_LAST_RESET][ + ATTR_TODAY_ENERGY_KWH + ] = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) + if emeter_statics.get(int(time.strftime("%e"))): + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = round( + float(emeter_statics[int(time.strftime("%e"))]), 3 + ) + else: + # today's consumption not available, when device was off all the day + data[CONF_EMETER_PARAMS][ATTR_TODAY_ENERGY_KWH] = 0.0 + except SmartDeviceException as ex: + raise UpdateFailed(ex) from ex + + self.name = data[CONF_ALIAS] + return data diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py index 3096281776a..6f6fb0a14c2 100644 --- a/homeassistant/components/tplink/common.py +++ b/homeassistant/components/tplink/common.py @@ -14,21 +14,20 @@ from pyHS100 import ( ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity -from .const import DOMAIN as TPLINK_DOMAIN +from .const import ( + CONF_DIMMER, + CONF_LIGHT, + CONF_STRIP, + CONF_SWITCH, + DOMAIN as TPLINK_DOMAIN, + MAX_DISCOVERY_RETRIES, +) _LOGGER = logging.getLogger(__name__) -ATTR_CONFIG = "config" -CONF_DIMMER = "dimmer" -CONF_DISCOVERY = "discovery" -CONF_LIGHT = "light" -CONF_STRIP = "strip" -CONF_SWITCH = "switch" -MAX_DISCOVERY_RETRIES = 4 - - class SmartDevices: """Hold different kinds of devices.""" @@ -98,7 +97,7 @@ async def async_discover_devices( else: _LOGGER.error("Unknown smart device type: %s", type(dev)) - devices = {} + devices: dict[str, SmartDevice] = {} for attempt in range(1, MAX_DISCOVERY_RETRIES + 1): _LOGGER.debug( "Discovering tplink devices, attempt %s of %s", @@ -136,7 +135,7 @@ def get_static_devices(config_data) -> SmartDevices: lights = [] switches = [] - for type_ in [CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER]: + for type_ in (CONF_LIGHT, CONF_SWITCH, CONF_STRIP, CONF_DIMMER): for entry in config_data[type_]: host = entry["host"] try: @@ -159,16 +158,18 @@ def get_static_devices(config_data) -> SmartDevices: def add_available_devices( hass: HomeAssistant, device_type: str, device_class: Callable -) -> list: +) -> list[Entity]: """Get sysinfo for all devices.""" - devices = hass.data[TPLINK_DOMAIN][device_type] + devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][device_type] if f"{device_type}_remaining" in hass.data[TPLINK_DOMAIN]: - devices = hass.data[TPLINK_DOMAIN][f"{device_type}_remaining"] + devices: list[SmartDevice] = hass.data[TPLINK_DOMAIN][ + f"{device_type}_remaining" + ] - entities_ready = [] - devices_unavailable = [] + entities_ready: list[Entity] = [] + devices_unavailable: list[SmartDevice] = [] for device in devices: try: device.get_sysinfo() diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py index 8b85b8afd74..60e06fd1ffe 100644 --- a/homeassistant/components/tplink/const.py +++ b/homeassistant/components/tplink/const.py @@ -1,5 +1,28 @@ """Const for TP-Link.""" +from __future__ import annotations + import datetime DOMAIN = "tplink" +COORDINATORS = "coordinators" +UNAVAILABLE_DEVICES = "unavailable_devices" +UNAVAILABLE_RETRY_DELAY = datetime.timedelta(seconds=300) + MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=8) +MAX_DISCOVERY_RETRIES = 4 + +ATTR_CONFIG = "config" +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" +ATTR_CURRENT_A = "current_a" + +CONF_MODEL = "model" +CONF_SW_VERSION = "sw_ver" +CONF_EMETER_PARAMS = "emeter_params" +CONF_DIMMER = "dimmer" +CONF_DISCOVERY = "discovery" +CONF_LIGHT = "light" +CONF_STRIP = "strip" +CONF_SWITCH = "switch" +CONF_SENSOR = "sensor" + +PLATFORMS = [CONF_LIGHT, CONF_SENSOR, CONF_SWITCH] diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py new file mode 100644 index 00000000000..697641915f7 --- /dev/null +++ b/homeassistant/components/tplink/sensor.py @@ -0,0 +1,158 @@ +"""Support for TPLink HS100/HS110/HS200 smart switch energy sensors.""" +from __future__ import annotations + +from typing import Any, Final + +from pyHS100 import SmartPlug + +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ALIAS, + CONF_DEVICE_ID, + CONF_MAC, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ( + CONF_EMETER_PARAMS, + CONF_MODEL, + CONF_SW_VERSION, + CONF_SWITCH, + COORDINATORS, + DOMAIN as TPLINK_DOMAIN, +) + +ATTR_CURRENT_A = "current_a" +ATTR_CURRENT_POWER_W = "current_power_w" +ATTR_TODAY_ENERGY_KWH = "today_energy_kwh" +ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" + +ENERGY_SENSORS: Final[list[SensorEntityDescription]] = [ + SensorEntityDescription( + key=ATTR_CURRENT_POWER_W, + unit_of_measurement=POWER_WATT, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + name="Current Consumption", + ), + SensorEntityDescription( + key=ATTR_TOTAL_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Total Consumption", + ), + SensorEntityDescription( + key=ATTR_TODAY_ENERGY_KWH, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + name="Today's Consumption", + ), + SensorEntityDescription( + key=ATTR_VOLTAGE, + unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + name="Voltage", + ), + SensorEntityDescription( + key=ATTR_CURRENT_A, + unit_of_measurement=ELECTRIC_CURRENT_AMPERE, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + name="Current", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches.""" + entities: list[SmartPlugSensor] = [] + coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ + COORDINATORS + ] + switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] + for switch in switches: + coordinator: SmartPlugDataUpdateCoordinator = coordinators[ + switch.context or switch.mac + ] + if not switch.has_emeter and coordinator.data.get(CONF_EMETER_PARAMS) is None: + continue + for description in ENERGY_SENSORS: + if coordinator.data[CONF_EMETER_PARAMS].get(description.key) is not None: + entities.append(SmartPlugSensor(switch, coordinator, description)) + + async_add_entities(entities) + + +class SmartPlugSensor(CoordinatorEntity, SensorEntity): + """Representation of a TPLink Smart Plug energy sensor.""" + + def __init__( + self, + smartplug: SmartPlug, + coordinator: DataUpdateCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.smartplug = smartplug + self.entity_description = description + self._attr_name = f"{coordinator.data[CONF_ALIAS]} {description.name}" + self._attr_last_reset = coordinator.data[CONF_EMETER_PARAMS][ + ATTR_LAST_RESET + ].get(description.key) + + @property + def data(self) -> dict[str, Any]: + """Return data from DataUpdateCoordinator.""" + return self.coordinator.data + + @property + def state(self) -> float | None: + """Return the sensors state.""" + return self.data[CONF_EMETER_PARAMS][self.entity_description.key] + + @property + def unique_id(self) -> str | None: + """Return a unique ID.""" + return f"{self.data[CONF_DEVICE_ID]}_{self.entity_description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return information about the device.""" + return { + "name": self.data[CONF_ALIAS], + "model": self.data[CONF_MODEL], + "manufacturer": "TP-Link", + "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, + "sw_version": self.data[CONF_SW_VERSION], + } diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index d088584c4ad..10cf5c64d75 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -1,40 +1,30 @@ """Support for TPLink HS100/HS110/HS200 smart switch.""" from __future__ import annotations -import asyncio -from collections.abc import Mapping -from contextlib import suppress -import logging -import time from typing import Any -from pyHS100 import SmartDeviceException, SmartPlug +from pyHS100 import SmartPlug -from homeassistant.components.switch import ( - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - SwitchEntity, -) +from homeassistant.components.switch import SwitchEntity +from homeassistant.components.tplink import SmartPlugDataUpdateCoordinator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_VOLTAGE +from homeassistant.const import CONF_ALIAS, CONF_DEVICE_ID, CONF_MAC, CONF_STATE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) -from . import CONF_SWITCH, DOMAIN as TPLINK_DOMAIN -from .common import add_available_devices - -PARALLEL_UPDATES = 0 - -_LOGGER = logging.getLogger(__name__) - -ATTR_TOTAL_ENERGY_KWH = "total_energy_kwh" -ATTR_CURRENT_A = "current_a" - -MAX_ATTEMPTS = 300 -SLEEP_TIME = 2 +from .const import ( + CONF_MODEL, + CONF_SW_VERSION, + CONF_SWITCH, + COORDINATORS, + DOMAIN as TPLINK_DOMAIN, +) async def async_setup_entry( @@ -43,164 +33,65 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches.""" - entities = await hass.async_add_executor_job( - add_available_devices, hass, CONF_SWITCH, SmartPlugSwitch - ) + entities: list[SmartPlugSwitch] = [] + coordinators: list[SmartPlugDataUpdateCoordinator] = hass.data[TPLINK_DOMAIN][ + COORDINATORS + ] + switches: list[SmartPlug] = hass.data[TPLINK_DOMAIN][CONF_SWITCH] + for switch in switches: + coordinator = coordinators[switch.context or switch.mac] + entities.append(SmartPlugSwitch(switch, coordinator)) - if entities: - async_add_entities(entities, update_before_add=True) - - if hass.data[TPLINK_DOMAIN][f"{CONF_SWITCH}_remaining"]: - raise PlatformNotReady + async_add_entities(entities) -class SmartPlugSwitch(SwitchEntity): +class SmartPlugSwitch(CoordinatorEntity, SwitchEntity): """Representation of a TPLink Smart Plug switch.""" - def __init__(self, smartplug: SmartPlug) -> None: + def __init__( + self, smartplug: SmartPlug, coordinator: DataUpdateCoordinator + ) -> None: """Initialize the switch.""" + super().__init__(coordinator) self.smartplug = smartplug - self._sysinfo = None - self._state = None - self._is_available = False - # Set up emeter cache - self._emeter_params = {} - self._mac = None - self._alias = None - self._model = None - self._device_id = None - self._host = None + @property + def data(self) -> dict[str, Any]: + """Return data from DataUpdateCoordinator.""" + return self.coordinator.data @property def unique_id(self) -> str | None: """Return a unique ID.""" - return self._device_id + return self.data[CONF_DEVICE_ID] @property def name(self) -> str | None: """Return the name of the Smart Plug.""" - return self._alias + return self.data[CONF_ALIAS] @property def device_info(self) -> DeviceInfo: """Return information about the device.""" return { - "name": self._alias, - "model": self._model, + "name": self.data[CONF_ALIAS], + "model": self.data[CONF_MODEL], "manufacturer": "TP-Link", - "connections": {(dr.CONNECTION_NETWORK_MAC, self._mac)}, - "sw_version": self._sysinfo["sw_ver"], + "connections": {(dr.CONNECTION_NETWORK_MAC, self.data[CONF_MAC])}, + "sw_version": self.data[CONF_SW_VERSION], } - @property - def available(self) -> bool: - """Return if switch is available.""" - return self._is_available - @property def is_on(self) -> bool | None: """Return true if switch is on.""" - return self._state + return self.data[CONF_STATE] - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - self.smartplug.turn_on() + await self.hass.async_add_executor_job(self.smartplug.turn_on) + await self.coordinator.async_refresh() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - self.smartplug.turn_off() - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of the device.""" - return self._emeter_params - - @property - def _plug_from_context(self) -> Any: - """Return the plug from the context.""" - children = self.smartplug.sys_info["children"] - return next(c for c in children if c["id"] == self.smartplug.context) - - def update_state(self) -> None: - """Update the TP-Link switch's state.""" - if self.smartplug.context is None: - self._state = self.smartplug.state == self.smartplug.SWITCH_STATE_ON - else: - self._state = self._plug_from_context["state"] == 1 - - def attempt_update(self, update_attempt: int) -> bool: - """Attempt to get details from the TP-Link switch.""" - try: - if not self._sysinfo: - self._sysinfo = self.smartplug.sys_info - self._mac = self._sysinfo["mac"] - self._model = self._sysinfo["model"] - self._host = self.smartplug.host - if self.smartplug.context is None: - self._alias = self._sysinfo["alias"] - self._device_id = self._mac - else: - self._alias = self._plug_from_context["alias"] - self._device_id = self.smartplug.context - - self.update_state() - - if self.smartplug.has_emeter: - emeter_readings = self.smartplug.get_emeter_realtime() - - self._emeter_params[ATTR_CURRENT_POWER_W] = round( - float(emeter_readings["power"]), 2 - ) - self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = round( - float(emeter_readings["total"]), 3 - ) - self._emeter_params[ATTR_VOLTAGE] = round( - float(emeter_readings["voltage"]), 1 - ) - self._emeter_params[ATTR_CURRENT_A] = round( - float(emeter_readings["current"]), 2 - ) - - emeter_statics = self.smartplug.get_emeter_daily() - with suppress(KeyError): # Device returned no daily history - self._emeter_params[ATTR_TODAY_ENERGY_KWH] = round( - float(emeter_statics[int(time.strftime("%e"))]), 3 - ) - return True - except (SmartDeviceException, OSError) as ex: - if update_attempt == 0: - _LOGGER.debug( - "Retrying in %s seconds for %s|%s due to: %s", - SLEEP_TIME, - self._host, - self._alias, - ex, - ) - return False - - async def async_update(self) -> None: - """Update the TP-Link switch's state.""" - for update_attempt in range(MAX_ATTEMPTS): - is_ready = await self.hass.async_add_executor_job( - self.attempt_update, update_attempt - ) - - if is_ready: - self._is_available = True - if update_attempt > 0: - _LOGGER.debug( - "Device %s|%s responded after %s attempts", - self._host, - self._alias, - update_attempt, - ) - break - await asyncio.sleep(SLEEP_TIME) - - else: - if self._is_available: - _LOGGER.warning( - "Could not read state for %s|%s", self.smartplug.host, self._alias - ) - self._is_available = False + await self.hass.async_add_executor_job(self.smartplug.turn_off) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/tplink/translations/de.json b/homeassistant/components/tplink/translations/de.json index 48571158085..6f804a6eeef 100644 --- a/homeassistant/components/tplink/translations/de.json +++ b/homeassistant/components/tplink/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine TP-Link-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/tplink/translations/he.json b/homeassistant/components/tplink/translations/he.json index 55d53f6e676..888c65226dc 100644 --- a/homeassistant/components/tplink/translations/he.json +++ b/homeassistant/components/tplink/translations/he.json @@ -1,8 +1,8 @@ { "config": { "abort": { - "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05e0\u05d9 TP-Link \u05d1\u05e8\u05e9\u05ea.", - "single_instance_allowed": "\u05e0\u05d3\u05e8\u05e9\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d1\u05dc\u05d1\u05d3" + "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd \u05d1\u05e8\u05e9\u05ea", + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, "step": { "confirm": { diff --git a/homeassistant/components/tplink/translations/hu.json b/homeassistant/components/tplink/translations/hu.json index ab799e90c74..bcfb467538d 100644 --- a/homeassistant/components/tplink/translations/hu.json +++ b/homeassistant/components/tplink/translations/hu.json @@ -3,6 +3,11 @@ "abort": { "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a TP-Link intelligens eszk\u00f6z\u00f6ket?" + } } } } \ No newline at end of file diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 661cb190877..5ad5879f31b 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -324,7 +324,7 @@ class TraccarScanner: "device_traccar_id": event["deviceId"], "device_name": device_name, "type": event["type"], - "serverTime": event["serverTime"], + "serverTime": event.get("eventTime") or event.get("serverTime"), "attributes": event["attributes"], }, ) diff --git a/homeassistant/components/traccar/translations/de.json b/homeassistant/components/traccar/translations/de.json index 7e253c1d05f..3e94aaeb4c5 100644 --- a/homeassistant/components/traccar/translations/de.json +++ b/homeassistant/components/traccar/translations/de.json @@ -5,7 +5,7 @@ "webhook_not_internet_accessible": "Deine Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Webhook-Nachrichten empfangen zu k\u00f6nnen." }, "create_entry": { - "default": "Um Ereignisse an den Heimassistenten zu senden, m\u00fcssen die Webhook-Funktionen in Traccar eingerichtet werden.\n\nVerwende die folgende URL: `{webhook_url}`\n\nSiehe [Dokumentation]({docs_url}) f\u00fcr weitere Details." + "default": "Um Ereignisse an den Heimassistenten zu senden, m\u00fcssen die Webhook-Funktionen in Traccar eingerichtet werden.\n\nVerwende die folgende URL: `{webhook_url}`\n\nSiehe [Dokumentation]({docs_url}) f\u00fcr weitere Details." }, "step": { "user": { diff --git a/homeassistant/components/tradfri/translations/de.json b/homeassistant/components/tradfri/translations/de.json index b1ebb2aff0b..ee72be60028 100644 --- a/homeassistant/components/tradfri/translations/de.json +++ b/homeassistant/components/tradfri/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Bridge ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt" }, "error": { diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 508ce659a4a..c6502bf0850 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.20.3"], + "requirements": ["numpy==1.21.1"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 595350324f9..7a639665948 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -197,7 +197,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_cancel_tuya_tracker(event): - domain_data[TUYA_TRACKER]() + domain_data[TUYA_TRACKER]() # pylint: disable=not-callable domain_data[STOP_CANCEL] = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, _async_cancel_tuya_tracker diff --git a/homeassistant/components/tuya/translations/de.json b/homeassistant/components/tuya/translations/de.json index 289d2661485..54fd3de7cbf 100644 --- a/homeassistant/components/tuya/translations/de.json +++ b/homeassistant/components/tuya/translations/de.json @@ -12,9 +12,9 @@ "step": { "user": { "data": { - "country_code": "L\u00e4ndercode Ihres Kontos (z. B. 1 f\u00fcr USA oder 86 f\u00fcr China)", + "country_code": "L\u00e4ndercode deines Kontos (z. B. 1 f\u00fcr USA oder 86 f\u00fcr China)", "password": "Passwort", - "platform": "Die App, in der Ihr Konto registriert ist", + "platform": "Die App, in der dein Konto registriert ist", "username": "Benutzername" }, "description": "Gib deine Tuya-Anmeldeinformationen ein.", @@ -53,11 +53,11 @@ "init": { "data": { "discovery_interval": "Abfrageintervall f\u00fcr Ger\u00e4teabruf in Sekunden", - "list_devices": "W\u00e4hlen Sie die zu konfigurierenden Ger\u00e4te aus oder lassen Sie sie leer, um die Konfiguration zu speichern", - "query_device": "W\u00e4hlen Sie ein Ger\u00e4t aus, das die Abfragemethode f\u00fcr eine schnellere Statusaktualisierung verwendet.", + "list_devices": "W\u00e4hle die zu konfigurierenden Ger\u00e4te aus oder lasse sie leer, um die Konfiguration zu speichern", + "query_device": "W\u00e4hle ein Ger\u00e4t aus, das die Abfragemethode f\u00fcr eine schnellere Statusaktualisierung verwendet.", "query_interval": "Ger\u00e4teabrufintervall in Sekunden" }, - "description": "Stellen Sie das Abfrageintervall nicht zu niedrig ein, sonst schlagen die Aufrufe fehl und erzeugen eine Fehlermeldung im Protokoll", + "description": "Stelle das Abfrageintervall nicht zu niedrig ein, sonst schlagen die Aufrufe fehl und erzeugen eine Fehlermeldung im Protokoll", "title": "Tuya-Optionen konfigurieren" } } diff --git a/homeassistant/components/tuya/translations/he.json b/homeassistant/components/tuya/translations/he.json index 0a05bec6b21..45980842a75 100644 --- a/homeassistant/components/tuya/translations/he.json +++ b/homeassistant/components/tuya/translations/he.json @@ -14,7 +14,7 @@ "data": { "country_code": "\u05e7\u05d5\u05d3 \u05de\u05d3\u05d9\u05e0\u05d4 \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da (\u05dc\u05de\u05e9\u05dc, 1 \u05dc\u05d0\u05e8\u05d4\"\u05d1 \u05d0\u05d5 972 \u05dc\u05d9\u05e9\u05e8\u05d0\u05dc)", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "platform": "\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05d1\u05d4 \u05e8\u05e9\u05d5\u05dd \u05d7\u05e9\u05d1\u05d5\u05e0\u05da", + "platform": "\u05d4\u05d9\u05d9\u05e9\u05d5\u05dd \u05d1\u05d5 \u05e8\u05e9\u05d5\u05dd \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" }, "description": "\u05d4\u05d6\u05df \u05d0\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05d8\u05d5\u05d9\u05d4 \u05e9\u05dc\u05da.", diff --git a/homeassistant/components/tuya/translations/hu.json b/homeassistant/components/tuya/translations/hu.json index b45148f80b8..054e6443d2a 100644 --- a/homeassistant/components/tuya/translations/hu.json +++ b/homeassistant/components/tuya/translations/hu.json @@ -40,8 +40,10 @@ "max_temp": "Maxim\u00e1lis c\u00e9l-sz\u00ednh\u0151m\u00e9rs\u00e9klet (haszn\u00e1lja a min-t \u00e9s a max-ot = 0-t az alap\u00e9rtelmezett be\u00e1ll\u00edt\u00e1shoz)", "min_kelvin": "Minimum t\u00e1mogatott sz\u00ednh\u0151m\u00e9rs\u00e9klet kelvinben", "min_temp": "Min. C\u00e9l-sz\u00ednh\u0151m\u00e9rs\u00e9klet (alap\u00e9rtelmez\u00e9s szerint haszn\u00e1ljon min-t \u00e9s max-ot = 0-t az alap\u00e9rtelmezett be\u00e1ll\u00edt\u00e1shoz)", + "set_temp_divided": "A h\u0151m\u00e9rs\u00e9klet be\u00e1ll\u00edt\u00e1s\u00e1hoz osztott h\u0151m\u00e9rs\u00e9kleti \u00e9rt\u00e9ket haszn\u00e1ljon", "support_color": "Sz\u00ednt\u00e1mogat\u00e1s k\u00e9nyszer\u00edt\u00e9se", "temp_divider": "Sz\u00ednh\u0151m\u00e9rs\u00e9klet-\u00e9rt\u00e9kek oszt\u00f3ja (0 = alap\u00e9rtelmezett)", + "temp_step_override": "C\u00e9lh\u0151m\u00e9rs\u00e9klet l\u00e9pcs\u0151", "tuya_max_coltemp": "Az eszk\u00f6z \u00e1ltal megadott maxim\u00e1lis sz\u00ednh\u0151m\u00e9rs\u00e9klet", "unit_of_measurement": "Az eszk\u00f6z \u00e1ltal haszn\u00e1lt h\u0151m\u00e9rs\u00e9kleti egys\u00e9g" }, diff --git a/homeassistant/components/twentemilieu/translations/de.json b/homeassistant/components/twentemilieu/translations/de.json index 38cabb6c22e..4ce9ed23ea7 100644 --- a/homeassistant/components/twentemilieu/translations/de.json +++ b/homeassistant/components/twentemilieu/translations/de.json @@ -14,7 +14,7 @@ "house_number": "Hausnummer", "post_code": "Postleitzahl" }, - "description": "Richte Twente Milieu mit Informationen zur Abfallsammlung unter Ihrer Adresse ein.", + "description": "Richte Twente Milieu mit Informationen zur Abfallsammlung unter deiner Adresse ein.", "title": "Twente Milieu" } } diff --git a/homeassistant/components/twilio/translations/de.json b/homeassistant/components/twilio/translations/de.json index d9f071e8ff7..73a3f244f96 100644 --- a/homeassistant/components/twilio/translations/de.json +++ b/homeassistant/components/twilio/translations/de.json @@ -9,7 +9,7 @@ }, "step": { "user": { - "description": "M\u00f6chten Sie mit der Einrichtung beginnen?", + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?", "title": "Twilio-Webhook einrichten" } } diff --git a/homeassistant/components/twinkly/translations/de.json b/homeassistant/components/twinkly/translations/de.json index 2e8bf218e08..0f5a7e0b886 100644 --- a/homeassistant/components/twinkly/translations/de.json +++ b/homeassistant/components/twinkly/translations/de.json @@ -9,9 +9,9 @@ "step": { "user": { "data": { - "host": "Host (oder IP-Adresse) Ihres twinkly-Ger\u00e4ts" + "host": "Host (oder IP-Adresse) deines twinkly-Ger\u00e4ts" }, - "description": "Einrichten Ihrer Twinkly-Led-Kette", + "description": "Einrichten deiner Twinkly-Led-Kette", "title": "Twinkly" } } diff --git a/homeassistant/components/twinkly/translations/hu.json b/homeassistant/components/twinkly/translations/hu.json index 190d7e469d5..d5cd872bbd0 100644 --- a/homeassistant/components/twinkly/translations/hu.json +++ b/homeassistant/components/twinkly/translations/hu.json @@ -8,6 +8,10 @@ }, "step": { "user": { + "data": { + "host": "A Twinkly eszk\u00f6z gazdag\u00e9pe (vagy IP-c\u00edme)" + }, + "description": "\u00c1ll\u00edtsa be a Twinkly led-karakterl\u00e1nc\u00e1t", "title": "Twinkly" } } diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index 41549c202b3..c66db9bb24b 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -183,12 +183,12 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor): """Return other details about the sensor state.""" attrs = {} if self._data is not None: - for key in [ + for key in ( ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, ATTR_REQUEST_TIME, - ]: + ): attrs[key] = self._data.get(key) attrs[ATTR_NEXT_BUSES] = self._next_buses return attrs diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 64963643447..faf51e6c853 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -38,7 +38,7 @@ CLIENT_CONNECTED_ATTRIBUTES = [ "ip", "is_11r", "is_guest", - "noted", + "note", "qos_policy_applied", "radio", "radio_proto", @@ -258,13 +258,11 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): """Return the client state attributes.""" raw = self.client.raw + attributes_to_check = CLIENT_STATIC_ATTRIBUTES if self.is_connected: - attributes = { - k: raw[k] for k in CLIENT_CONNECTED_ALL_ATTRIBUTES if k in raw - } - else: - attributes = {k: raw[k] for k in CLIENT_STATIC_ATTRIBUTES if k in raw} + attributes_to_check = CLIENT_CONNECTED_ALL_ATTRIBUTES + attributes = {k: raw[k] for k in attributes_to_check if k in raw} attributes["is_wired"] = self.is_wired return attributes diff --git a/homeassistant/components/unifi/translations/de.json b/homeassistant/components/unifi/translations/de.json index f1f2fdd3627..ab7f9bb9b16 100644 --- a/homeassistant/components/unifi/translations/de.json +++ b/homeassistant/components/unifi/translations/de.json @@ -33,19 +33,19 @@ "dpi_restrictions": "Zulassen der Steuerung von DPI-Einschr\u00e4nkungsgruppen", "poe_clients": "POE-Kontrolle von Clients zulassen" }, - "description": "Konfigurieren Sie Client-Steuerelemente \n\nErstellen Sie Switches f\u00fcr Seriennummern, f\u00fcr die Sie den Netzwerkzugriff steuern m\u00f6chten.", + "description": "Konfiguriere Client-Steuerelemente \n\nErstelle Switches f\u00fcr Seriennummern, f\u00fcr die du den Netzwerkzugriff steuern m\u00f6chtest.", "title": "UniFi-Optionen 2/3" }, "device_tracker": { "data": { "detection_time": "Zeit in Sekunden vom letzten Gesehenen bis zur Entfernung", "ignore_wired_bug": "Deaktivieren der kabelgebundenen UniFi-Fehlerlogik", - "ssid_filter": "W\u00e4hlen Sie SSIDs zur Verfolgung von drahtlosen Clients aus", + "ssid_filter": "W\u00e4hle SSIDs zur Verfolgung von drahtlosen Clients aus", "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)", "track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients" }, - "description": "Konfigurieren Sie die Ger\u00e4teverfolgung", + "description": "Konfiguriere die Ger\u00e4teverfolgung", "title": "UniFi-Optionen 1/3" }, "init": { @@ -57,17 +57,17 @@ "simple_options": { "data": { "block_client": "Clients mit Netzwerkzugriffskontrolle", - "track_clients": "Netzwerger\u00e4te \u00fcberwachen", + "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)" }, - "description": "Konfigurieren Sie die UniFi-Integration" + "description": "Konfiguriere die UniFi-Integration" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Bandbreitennutzungssensoren f\u00fcr Netzwerkclients", "allow_uptime_sensors": "Uptime-Sensoren f\u00fcr Netzwerk-Clients" }, - "description": "Konfigurieren Sie Statistiksensoren", + "description": "Konfiguriere die Statistiksensoren", "title": "UniFi-Optionen 3/3" } } diff --git a/homeassistant/components/unifi/translations/he.json b/homeassistant/components/unifi/translations/he.json index 83c34cb9c77..4fe52a3cf8b 100644 --- a/homeassistant/components/unifi/translations/he.json +++ b/homeassistant/components/unifi/translations/he.json @@ -33,6 +33,14 @@ "track_devices": "\u05de\u05e2\u05e7\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9 \u05e8\u05e9\u05ea (\u05d4\u05ea\u05e7\u05e0\u05d9 Ubiquiti)" } }, + "init": { + "data": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + } + }, "simple_options": { "data": { "block_client": "\u05dc\u05e7\u05d5\u05d7\u05d5\u05ea \u05de\u05d1\u05d5\u05e7\u05e8\u05d9\u05dd \u05e9\u05dc \u05d2\u05d9\u05e9\u05d4 \u05dc\u05e8\u05e9\u05ea", diff --git a/homeassistant/components/unifi/translations/hu.json b/homeassistant/components/unifi/translations/hu.json index 745b628b253..5c174e9939d 100644 --- a/homeassistant/components/unifi/translations/hu.json +++ b/homeassistant/components/unifi/translations/hu.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "A vez\u00e9rl\u0151 webhelye m\u00e1r konfigur\u00e1lva van", "configuration_updated": "A konfigur\u00e1ci\u00f3 friss\u00edtve.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, @@ -26,6 +27,9 @@ "options": { "step": { "client_control": { + "data": { + "dpi_restrictions": "Enged\u00e9lyezze a DPI restrikci\u00f3s csoportok vez\u00e9rl\u00e9s\u00e9t" + }, "description": "Konfigur\u00e1lja a klienseket\n\n Hozzon l\u00e9tre kapcsol\u00f3kat azokhoz a sorsz\u00e1mokhoz, amelyeknek vez\u00e9relni k\u00edv\u00e1nja a h\u00e1l\u00f3zati hozz\u00e1f\u00e9r\u00e9st." }, "simple_options": { @@ -33,7 +37,8 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "S\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa a h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" + "allow_bandwidth_sensors": "S\u00e1vsz\u00e9less\u00e9g-haszn\u00e1lati \u00e9rz\u00e9kel\u0151k l\u00e9trehoz\u00e1sa a h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra", + "allow_uptime_sensors": "\u00dczemid\u0151-\u00e9rz\u00e9kel\u0151k h\u00e1l\u00f3zati \u00fcgyfelek sz\u00e1m\u00e1ra" } } } diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 17dcde55381..a2981852dc1 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -472,7 +472,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): if SERVICE_MEDIA_PREVIOUS_TRACK in self._cmds: flags |= SUPPORT_PREVIOUS_TRACK - if any(cmd in self._cmds for cmd in [SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN]): + if any(cmd in self._cmds for cmd in (SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN)): flags |= SUPPORT_VOLUME_STEP if SERVICE_VOLUME_SET in self._cmds: flags |= SUPPORT_VOLUME_SET diff --git a/homeassistant/components/upb/translations/de.json b/homeassistant/components/upb/translations/de.json index 86e4d7409cf..7c0454e7707 100644 --- a/homeassistant/components/upb/translations/de.json +++ b/homeassistant/components/upb/translations/de.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_upb_file": "Fehlende oder ung\u00fcltige UPB UPStart-Exportdatei, \u00fcberpr\u00fcfe den Namen und den Pfad der Datei.", + "invalid_upb_file": "Fehlende oder ung\u00fcltige UPB UPStart-Exportdatei, \u00fcberpr\u00fcfe den Namen und den Pfad der Datei.", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/upb/translations/he.json b/homeassistant/components/upb/translations/he.json index e89a02adfa4..7c7490161fe 100644 --- a/homeassistant/components/upb/translations/he.json +++ b/homeassistant/components/upb/translations/he.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4" + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" }, "step": { "user": { diff --git a/homeassistant/components/updater/translations/he.json b/homeassistant/components/updater/translations/he.json index de8c2468f90..38072833421 100644 --- a/homeassistant/components/updater/translations/he.json +++ b/homeassistant/components/updater/translations/he.json @@ -1,3 +1,3 @@ { - "title": "\u05d4\u05de\u05e2\u05d3\u05db\u05df" + "title": "\u05de\u05e2\u05d3\u05db\u05df" } \ No newline at end of file diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 5788ec1b3ef..6ad7111ae12 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -6,12 +6,13 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.network.const import PUBLIC_TARGET_IP from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from homeassistant.util import get_local_ip from .const import ( CONF_LOCAL_IP, @@ -63,7 +64,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType): _LOGGER.debug("async_setup, config: %s", config) conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] conf = config.get(DOMAIN, conf_default) - local_ip = await hass.async_add_executor_job(get_local_ip) + local_ip = await async_get_source_ip(hass, PUBLIC_TARGET_IP) hass.data[DOMAIN] = { DOMAIN_CONFIG: conf, DOMAIN_DEVICES: {}, diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index f52ce89660d..0679d9ffcb5 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -166,8 +166,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery = discovery_info_to_discovery(discovery_info) # Ensure not already configuring/configured. - discovery = await Device.async_supplement_discovery(self.hass, discovery) - unique_id = discovery[DISCOVERY_UNIQUE_ID] + unique_id = discovery[DISCOVERY_USN] await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={CONFIG_ENTRY_HOSTNAME: discovery[DISCOVERY_HOSTNAME]} @@ -183,6 +182,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="discovery_ignored") + # Get more data about the device. + discovery = await Device.async_supplement_discovery(self.hass, discovery) + # Store discovery. self._discoveries = [discovery] diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 9af7cf55c24..cf76aa41f8a 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -40,11 +40,15 @@ from .const import ( def discovery_info_to_discovery(discovery_info: Mapping) -> Mapping: """Convert a SSDP-discovery to 'our' discovery.""" + location = discovery_info[ssdp.ATTR_SSDP_LOCATION] + parsed = urlparse(location) + hostname = parsed.hostname return { DISCOVERY_UDN: discovery_info[ssdp.ATTR_UPNP_UDN], DISCOVERY_ST: discovery_info[ssdp.ATTR_SSDP_ST], DISCOVERY_LOCATION: discovery_info[ssdp.ATTR_SSDP_LOCATION], DISCOVERY_USN: discovery_info[ssdp.ATTR_SSDP_USN], + DISCOVERY_HOSTNAME: hostname, } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 810a53c9e28..41d50b4bae8 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", "requirements": ["async-upnp-client==0.19.1"], - "dependencies": ["ssdp"], + "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ { diff --git a/homeassistant/components/upnp/translations/ar.json b/homeassistant/components/upnp/translations/ar.json new file mode 100644 index 00000000000..b1767243354 --- /dev/null +++ b/homeassistant/components/upnp/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "scan_interval": "\u0641\u062a\u0631\u0629 \u062a\u0643\u0631\u0627\u0631 \u0627\u0644\u062a\u062d\u062f\u064a\u062b (\u0628\u0627\u0644\u062b\u0648\u0627\u0646\u064a \u060c 30 \u0639\u0644\u0649 \u0627\u0644\u0623\u0642\u0644)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/de.json b/homeassistant/components/upnp/translations/de.json index a43700ba236..b63d17947ae 100644 --- a/homeassistant/components/upnp/translations/de.json +++ b/homeassistant/components/upnp/translations/de.json @@ -1,9 +1,9 @@ { "config": { "abort": { - "already_configured": "UPnP/IGD ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "incomplete_discovery": "Unvollst\u00e4ndige Suche", - "no_devices_found": "Keine UPnP/IGD-Ger\u00e4te im Netzwerk gefunden." + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden" }, "error": { "one": "Ein", @@ -12,7 +12,7 @@ "flow_title": "{name}", "step": { "ssdp_confirm": { - "description": "M\u00f6chten Sie dieses UPnP/IGD-Ger\u00e4t einrichten?" + "description": "M\u00f6chtest du dieses UPnP/IGD-Ger\u00e4t einrichten?" }, "user": { "data": { diff --git a/homeassistant/components/upnp/translations/fr.json b/homeassistant/components/upnp/translations/fr.json index fe1f1366d39..ffbef69abe7 100644 --- a/homeassistant/components/upnp/translations/fr.json +++ b/homeassistant/components/upnp/translations/fr.json @@ -26,5 +26,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Intervalle de mise \u00e0 jour (secondes, minimum 30)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/he.json b/homeassistant/components/upnp/translations/he.json index 706d87f0db4..e9aba0a7a58 100644 --- a/homeassistant/components/upnp/translations/he.json +++ b/homeassistant/components/upnp/translations/he.json @@ -12,6 +12,12 @@ }, "flow_title": "{name}", "step": { + "init": { + "many": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "one": "\u05e8\u05d9\u05e7", + "other": "\u05e8\u05d9\u05e7\u05d9\u05dd", + "two": "\u05e8\u05d9\u05e7\u05d9\u05dd" + }, "user": { "data": { "unique_id": "\u05d4\u05ea\u05e7\u05df", diff --git a/homeassistant/components/upnp/translations/hu.json b/homeassistant/components/upnp/translations/hu.json index 0bffeeaf154..49756babc8b 100644 --- a/homeassistant/components/upnp/translations/hu.json +++ b/homeassistant/components/upnp/translations/hu.json @@ -12,9 +12,19 @@ "step": { "user": { "data": { + "unique_id": "Eszk\u00f6z", "usn": "Eszk\u00f6z" } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Friss\u00edt\u00e9si intervallum (m\u00e1sodperc, minimum 30)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/upnp/translations/id.json b/homeassistant/components/upnp/translations/id.json index 463e61f271c..3a953ba62a9 100644 --- a/homeassistant/components/upnp/translations/id.json +++ b/homeassistant/components/upnp/translations/id.json @@ -13,9 +13,19 @@ "user": { "data": { "scan_interval": "Interval pembaruan (dalam detik, minimal 30)", + "unique_id": "Perangkat", "usn": "Perangkat" } } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Interval pembaruan (dalam detik, minimal 30)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 98c673b8878..5b31b2e81d0 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -47,25 +47,7 @@ class UptimeSensor(SensorEntity): def __init__(self, name: str) -> None: """Initialize the uptime sensor.""" - self._name = name - self._state = dt_util.now().isoformat() - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - - @property - def device_class(self) -> str: - """Return device class.""" - return DEVICE_CLASS_TIMESTAMP - - @property - def state(self) -> str: - """Return the state of the sensor.""" - return self._state - - @property - def should_poll(self) -> bool: - """Disable polling for this entity.""" - return False + self._attr_name: str = name + self._attr_device_class: str = DEVICE_CLASS_TIMESTAMP + self._attr_should_poll: bool = False + self._attr_state: str = dt_util.now().isoformat() diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index 6c0bb63c70f..e1684d64924 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -1,6 +1,8 @@ """A platform that to monitor Uptime Robot monitors.""" +from datetime import timedelta import logging +import async_timeout from pyuptimerobot import UptimeRobot import voluptuous as vol @@ -8,9 +10,17 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, PLATFORM_SCHEMA, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) _LOGGER = logging.getLogger(__name__) @@ -21,69 +31,82 @@ ATTRIBUTION = "Data provided by Uptime Robot" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_API_KEY): cv.string}) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform( + hass: HomeAssistant, config, async_add_entities, discovery_info=None +): """Set up the Uptime Robot binary_sensors.""" + uptime_robot_api = UptimeRobot() + api_key = config[CONF_API_KEY] - up_robot = UptimeRobot() - api_key = config.get(CONF_API_KEY) - monitors = up_robot.getMonitors(api_key) + def api_wrapper(): + return uptime_robot_api.getMonitors(api_key) - devices = [] - if not monitors or monitors.get("stat") != "ok": + async def async_update_data(): + """Fetch data from API UptimeRobot API.""" + async with async_timeout.timeout(10): + monitors = await hass.async_add_executor_job(api_wrapper) + if not monitors or monitors.get("stat") != "ok": + raise UpdateFailed("Error communicating with Uptime Robot API") + return monitors + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="uptimerobot", + update_method=async_update_data, + update_interval=timedelta(seconds=60), + ) + + await coordinator.async_refresh() + + if not coordinator.data or coordinator.data.get("stat") != "ok": _LOGGER.error("Error connecting to Uptime Robot") - return + raise PlatformNotReady() - for monitor in monitors["monitors"]: - devices.append( + async_add_entities( + [ UptimeRobotBinarySensor( - api_key, - up_robot, - monitor["id"], - monitor["friendly_name"], - monitor["url"], + coordinator, + BinarySensorEntityDescription( + key=monitor["id"], + name=monitor["friendly_name"], + device_class=DEVICE_CLASS_CONNECTIVITY, + ), + target=monitor["url"], ) - ) - - add_entities(devices, True) + for monitor in coordinator.data["monitors"] + ], + ) -class UptimeRobotBinarySensor(BinarySensorEntity): +class UptimeRobotBinarySensor(BinarySensorEntity, CoordinatorEntity): """Representation of a Uptime Robot binary sensor.""" - def __init__(self, api_key, up_robot, monitor_id, name, target): + def __init__( + self, + coordinator: DataUpdateCoordinator, + description: BinarySensorEntityDescription, + target: str, + ) -> None: """Initialize Uptime Robot the binary sensor.""" - self._api_key = api_key - self._monitor_id = str(monitor_id) - self._name = name + super().__init__(coordinator) + self.entity_description = description self._target = target - self._up_robot = up_robot - self._state = None + self._attr_extra_state_attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_TARGET: self._target, + } @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return DEVICE_CLASS_CONNECTIVITY - - @property - def extra_state_attributes(self): - """Return the state attributes of the binary sensor.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_TARGET: self._target} - - def update(self): - """Get the latest state of the binary sensor.""" - monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id) - if not monitor or monitor.get("stat") != "ok": - _LOGGER.warning("Failed to get new state") - return - status = monitor["monitors"][0]["status"] - self._state = 1 if status == 2 else 0 + def is_on(self) -> bool: + """Return True if the entity is on.""" + if monitor := next( + ( + monitor + for monitor in self.coordinator.data.get("monitors", []) + if monitor["id"] == self.entity_description.key + ), + None, + ): + return monitor["status"] == 2 + return False diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 5442cd583e2..25aa6018d44 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -76,7 +76,7 @@ async def async_setup(hass, config): hass, SENSOR_DOMAIN, DOMAIN, - [{CONF_METER: meter, CONF_NAME: meter}], + [{CONF_METER: meter, CONF_NAME: conf.get(CONF_NAME, meter)}], config, ) ) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 36cc632d932..87d8d6f49f8 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -1,4 +1,5 @@ """Support for vacuum cleaner robots (botvacs).""" +from dataclasses import dataclass from datetime import timedelta from functools import partial import logging @@ -24,7 +25,12 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA_BASE, make_entity_service_schema, ) -from homeassistant.helpers.entity import Entity, ToggleEntity +from homeassistant.helpers.entity import ( + Entity, + EntityDescription, + ToggleEntity, + ToggleEntityDescription, +) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.loader import bind_hass @@ -258,9 +264,16 @@ class _BaseVacuum(Entity): ) +@dataclass +class VacuumEntityDescription(ToggleEntityDescription): + """A class that describes vacuum entities.""" + + class VacuumEntity(_BaseVacuum, ToggleEntity): """Representation of a vacuum cleaner robot.""" + entity_description: VacuumEntityDescription + @property def status(self): """Return the status of the vacuum cleaner.""" @@ -338,9 +351,16 @@ class VacuumDevice(VacuumEntity): ) +@dataclass +class StateVacuumEntityDescription(EntityDescription): + """A class that describes vacuum entities.""" + + class StateVacuumEntity(_BaseVacuum): """Representation of a vacuum cleaner robot that supports states.""" + entity_description: StateVacuumEntityDescription + @property def state(self): """Return the state of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/translations/ar.json b/homeassistant/components/vacuum/translations/ar.json index 2e9d6c9a5d6..630b54d4676 100644 --- a/homeassistant/components/vacuum/translations/ar.json +++ b/homeassistant/components/vacuum/translations/ar.json @@ -3,6 +3,7 @@ "_": { "cleaning": "\u062a\u0646\u0638\u064a\u0641", "error": "\u062e\u0637\u0623", + "idle": "\u062e\u0627\u0645\u0644", "off": "\u0645\u0637\u0641\u0626", "on": "\u0645\u0634\u063a\u0644", "paused": "\u0645\u0648\u0642\u0651\u0641 \u0645\u0624\u0642\u062a\u0627", diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index eb5edfe7fcf..27210e0c750 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -112,8 +112,8 @@ async def async_setup(hass, config): hass.data[DOMAIN] = {"client": client, "state_proxy": state_proxy, "name": name} - for vallox_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[vallox_service]["schema"] + for vallox_service, method in SERVICE_TO_METHOD.items(): + schema = method["schema"] hass.services.async_register( DOMAIN, vallox_service, service_handler.async_handle, schema=schema ) diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index b4269ac4451..ddfb9d1a7d3 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -5,6 +5,8 @@ import logging from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_CO2, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, @@ -91,6 +93,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= unit_of_measurement=None, icon="mdi:filter", ), + ValloxSensor( + name=f"{name} Efficiency", + state_proxy=state_proxy, + metric_key="A_CYC_EXTRACT_EFFICIENCY", + device_class=None, + unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + ), + ValloxSensor( + name=f"{name} CO2", + state_proxy=state_proxy, + metric_key="A_CYC_CO2_VALUE", + device_class=DEVICE_CLASS_CO2, + unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon=None, + ), ] async_add_entities(sensors, update_before_add=False) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 5c1d8bfd370..6783c71b3cd 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -5,12 +5,14 @@ from pyvlx import PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity DOMAIN = "velux" DATA_VELUX = "data_velux" -PLATFORMS = ["cover", "scene"] +PLATFORMS = ["cover", "light", "scene"] _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -75,3 +77,42 @@ class VeluxModule: _LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() + + +class VeluxEntity(Entity): + """Abstraction for al Velux entities.""" + + def __init__(self, node): + """Initialize the Velux device.""" + self.node = node + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + + async def after_update_callback(device): + """Call after device was updated.""" + self.async_write_ha_state() + + self.node.register_device_updated_cb(after_update_callback) + + async def async_added_to_hass(self): + """Store register state change callback.""" + self.async_register_callbacks() + + @property + def unique_id(self) -> str: + """Return the unique id base on the serial_id returned by Velux.""" + return self.node.serial_number + + @property + def name(self): + """Return the name of the Velux device.""" + if not self.node.name: + return "#" + str(self.node.node_id) + return self.node.name + + @property + def should_poll(self): + """No polling needed within Velux.""" + return False diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 187c0d36178..3bb228dd425 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -21,9 +21,8 @@ from homeassistant.components.cover import ( SUPPORT_STOP_TILT, CoverEntity, ) -from homeassistant.core import callback -from . import DATA_VELUX +from . import DATA_VELUX, VeluxEntity async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -35,42 +34,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(entities) -class VeluxCover(CoverEntity): +class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" - def __init__(self, node): - """Initialize the cover.""" - self.node = node - - @callback - def async_register_callbacks(self): - """Register callbacks to update hass after device was changed.""" - - async def after_update_callback(device): - """Call after device was updated.""" - self.async_write_ha_state() - - self.node.register_device_updated_cb(after_update_callback) - - async def async_added_to_hass(self): - """Store register state change callback.""" - self.async_register_callbacks() - - @property - def unique_id(self): - """Return the unique ID of this cover.""" - return self.node.serial_number - - @property - def name(self): - """Return the name of the Velux device.""" - return self.node.name - - @property - def should_poll(self): - """No polling needed within Velux.""" - return False - @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py new file mode 100644 index 00000000000..2e33b267e43 --- /dev/null +++ b/homeassistant/components/velux/light.py @@ -0,0 +1,56 @@ +"""Support for Velux lights.""" +from pyvlx import Intensity, LighteningDevice +from pyvlx.node import Node + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, + LightEntity, +) + +from . import DATA_VELUX, VeluxEntity + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up light(s) for Velux platform.""" + async_add_entities( + VeluxLight(node) + for node in hass.data[DATA_VELUX].pyvlx.nodes + if isinstance(node, LighteningDevice) + ) + + +class VeluxLight(VeluxEntity, LightEntity): + """Representation of a Velux light.""" + + def __init__(self, node: Node) -> None: + """Initialize the Velux light.""" + super().__init__(node) + + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + self._attr_color_mode = COLOR_MODE_BRIGHTNESS + + @property + def brightness(self): + """Return the current brightness.""" + return int((100 - self.node.intensity.intensity_percent) * 255 / 100) + + @property + def is_on(self): + """Return true if light is on.""" + return not self.node.intensity.off and self.node.intensity.known + + async def async_turn_on(self, **kwargs): + """Instruct the light to turn on.""" + if ATTR_BRIGHTNESS in kwargs: + intensity_percent = int(100 - kwargs[ATTR_BRIGHTNESS] / 255 * 100) + await self.node.set_intensity( + Intensity(intensity_percent=intensity_percent), + wait_for_completion=True, + ) + else: + await self.node.turn_on(wait_for_completion=True) + + async def async_turn_off(self, **kwargs): + """Instruct the light to turn off.""" + await self.node.turn_off(wait_for_completion=True) diff --git a/homeassistant/components/vera/translations/de.json b/homeassistant/components/vera/translations/de.json index 7fce8f6bdfe..999dfc8213f 100644 --- a/homeassistant/components/vera/translations/de.json +++ b/homeassistant/components/vera/translations/de.json @@ -10,8 +10,8 @@ "lights": "Vera Switch-Ger\u00e4te-IDs, die im Home Assistant als Lichter behandelt werden sollen.", "vera_controller_url": "Controller-URL" }, - "description": "Stellen Sie unten eine Vera-Controller-URL zur Verf\u00fcgung. Sie sollte wie folgt aussehen: http://192.168.1.161:3480.", - "title": "Richten Sie den Vera-Controller ein" + "description": "Stelle unten eine Vera-Controller-URL zur Verf\u00fcgung. Sie sollte wie folgt aussehen: http://192.168.1.161:3480.", + "title": "Richte den Vera-Controller ein" } } }, @@ -22,7 +22,7 @@ "exclude": "Vera-Ger\u00e4te-IDs, die vom Home Assistant ausgeschlossen werden sollen.", "lights": "Vera Switch-Ger\u00e4te-IDs, die im Home Assistant als Lichter behandelt werden sollen." }, - "description": "Weitere Informationen zu optionalen Parametern finden Sie in der Vera-Dokumentation: https://www.home-assistant.io/integrations/vera/. Hinweis: Alle \u00c4nderungen hier erfordern einen Neustart des Home Assistant-Servers. Geben Sie ein Leerzeichen ein, um Werte zu l\u00f6schen.", + "description": "Weitere Informationen zu optionalen Parametern findest du in der Vera-Dokumentation: https://www.home-assistant.io/integrations/vera/. Hinweis: Alle \u00c4nderungen hier erfordern einen Neustart des Home Assistant-Servers. Gib ein Leerzeichen ein, um Werte zu l\u00f6schen.", "title": "Vera Controller Optionen" } } diff --git a/homeassistant/components/verisure/translations/hu.json b/homeassistant/components/verisure/translations/hu.json index 85e53003566..f071872b81c 100644 --- a/homeassistant/components/verisure/translations/hu.json +++ b/homeassistant/components/verisure/translations/hu.json @@ -12,7 +12,8 @@ "installation": { "data": { "giid": "Telep\u00edt\u00e9s" - } + }, + "description": "A Home Assistant t\u00f6bb Verisure telep\u00edt\u00e9st tal\u00e1lt a Saj\u00e1t oldalak fi\u00f3kodban. K\u00e9rj\u00fck, v\u00e1lassza ki azt a telep\u00edt\u00e9st, amelyet hozz\u00e1 k\u00edv\u00e1n adni a Home Assistant programhoz." }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 3a17af55c93..48a7a577313 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -2,9 +2,7 @@ import logging from pyvesync import VeSync -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -25,42 +23,7 @@ PLATFORMS = ["switch", "fan", "light"] _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - """Set up the VeSync component.""" - conf = config.get(DOMAIN) - - if conf is None: - return True - - if not hass.config_entries.async_entries(DOMAIN): - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_USERNAME: conf[CONF_USERNAME], - CONF_PASSWORD: conf[CONF_PASSWORD], - }, - ) - ) - - return True +CONFIG_SCHEMA = cv.deprecated(DOMAIN) async def async_setup_entry(hass, config_entry): diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index f91848c5238..d9cd1bfc648 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -33,10 +33,6 @@ class VeSyncFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors if errors else {}, ) - async def async_step_import(self, import_config): - """Handle external yaml configuration.""" - return await self.async_step_user(import_config) - async def async_step_user(self, user_input=None): """Handle a flow start.""" if self._async_current_entries(): diff --git a/homeassistant/components/vesync/translations/de.json b/homeassistant/components/vesync/translations/de.json index ea05a60ff82..bd1ba32fb4a 100644 --- a/homeassistant/components/vesync/translations/de.json +++ b/homeassistant/components/vesync/translations/de.json @@ -10,7 +10,7 @@ "user": { "data": { "password": "Passwort", - "username": "E-Mail-Adresse" + "username": "E-Mail" }, "title": "Benutzername und Passwort eingeben" } diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 88c4ce33a86..f3ffd7e1db6 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -9,6 +9,7 @@ from PyViCare.PyViCareHeatPump import HeatPump import voluptuous as vol from homeassistant.const import ( + CONF_CLIENT_ID, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, @@ -23,7 +24,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = ["climate", "sensor", "binary_sensor", "water_heater"] DOMAIN = "vicare" -PYVICARE_ERROR = "error" VICARE_API = "api" VICARE_NAME = "name" VICARE_HEATING_TYPE = "heating_type" @@ -48,6 +48,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, vol.Optional(CONF_SCAN_INTERVAL, default=60): vol.All( cv.time_period, lambda value: value.total_seconds() ), @@ -71,7 +72,7 @@ def setup(hass, config): params["circuit"] = conf[CONF_CIRCUIT] params["cacheDuration"] = conf.get(CONF_SCAN_INTERVAL) - + params["client_id"] = conf.get(CONF_CLIENT_ID) heating_type = conf[CONF_HEATING_TYPE] try: diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 823c4f1ba1b..0c98d22e9ae 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -1,6 +1,8 @@ """Viessmann ViCare sensor device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.binary_sensor import ( @@ -11,7 +13,6 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -29,10 +30,6 @@ SENSOR_BURNER_ACTIVE = "burner_active" # heatpump sensors SENSOR_COMPRESSOR_ACTIVE = "compressor_active" -SENSOR_HEATINGROD_OVERALL = "heatingrod_overall" -SENSOR_HEATINGROD_LEVEL1 = "heatingrod_level1" -SENSOR_HEATINGROD_LEVEL2 = "heatingrod_level2" -SENSOR_HEATINGROD_LEVEL3 = "heatingrod_level3" SENSOR_TYPES = { SENSOR_CIRCULATION_PUMP_ACTIVE: { @@ -52,26 +49,6 @@ SENSOR_TYPES = { CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, CONF_GETTER: lambda api: api.getCompressorActive(), }, - SENSOR_HEATINGROD_OVERALL: { - CONF_NAME: "Heating rod overall", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusOverall(), - }, - SENSOR_HEATINGROD_LEVEL1: { - CONF_NAME: "Heating rod level 1", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel1(), - }, - SENSOR_HEATINGROD_LEVEL2: { - CONF_NAME: "Heating rod level 2", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel2(), - }, - SENSOR_HEATINGROD_LEVEL3: { - CONF_NAME: "Heating rod level 3", - CONF_DEVICE_CLASS: DEVICE_CLASS_POWER, - CONF_GETTER: lambda api: api.getHeatingRodStatusLevel3(), - }, } SENSORS_GENERIC = [SENSOR_CIRCULATION_PUMP_ACTIVE] @@ -80,10 +57,6 @@ SENSORS_BY_HEATINGTYPE = { HeatingType.gas: [SENSOR_BURNER_ACTIVE], HeatingType.heatpump: [ SENSOR_COMPRESSOR_ACTIVE, - SENSOR_HEATINGROD_OVERALL, - SENSOR_HEATINGROD_LEVEL1, - SENSOR_HEATINGROD_LEVEL2, - SENSOR_HEATINGROD_LEVEL3, ], HeatingType.fuelcell: [SENSOR_BURNER_ACTIVE], } @@ -126,7 +99,7 @@ class ViCareBinarySensor(BinarySensorEntity): @property def available(self): """Return True if entity is available.""" - return self._state is not None and self._state != PYVICARE_ERROR + return self._state is not None @property def unique_id(self): @@ -151,8 +124,11 @@ class ViCareBinarySensor(BinarySensorEntity): def update(self): """Update state of sensor.""" try: - self._state = self._sensor[CONF_GETTER](self._api) + with suppress(PyViCareNotSupportedFeatureError): + self._state = self._sensor[CONF_GETTER](self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index cfbfa1ddec6..2822d048152 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -1,6 +1,8 @@ """Viessmann ViCare climate device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests import voluptuous as vol @@ -21,7 +23,6 @@ from homeassistant.helpers import entity_platform from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -136,47 +137,58 @@ class ViCareClimate(ClimateEntity): def update(self): """Let HA know there has been an update from the ViCare API.""" try: - _room_temperature = self._api.getRoomTemperature() - _supply_temperature = self._api.getSupplyTemperature() - if _room_temperature is not None and _room_temperature != PYVICARE_ERROR: + _room_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + _room_temperature = self._api.getRoomTemperature() + + _supply_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + _supply_temperature = self._api.getSupplyTemperature() + + if _room_temperature is not None: self._current_temperature = _room_temperature - elif _supply_temperature != PYVICARE_ERROR: + elif _supply_temperature is not None: self._current_temperature = _supply_temperature else: self._current_temperature = None - self._current_program = self._api.getActiveProgram() - # The getCurrentDesiredTemperature call can yield 'error' (str) when the system is in standby - desired_temperature = self._api.getCurrentDesiredTemperature() - if desired_temperature == PYVICARE_ERROR: - desired_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + self._current_program = self._api.getActiveProgram() - self._target_temperature = desired_temperature + with suppress(PyViCareNotSupportedFeatureError): + self._target_temperature = self._api.getCurrentDesiredTemperature() - self._current_mode = self._api.getActiveMode() + with suppress(PyViCareNotSupportedFeatureError): + self._current_mode = self._api.getActiveMode() # Update the generic device attributes self._attributes = {} + self._attributes["room_temperature"] = _room_temperature self._attributes["active_vicare_program"] = self._current_program self._attributes["active_vicare_mode"] = self._current_mode - self._attributes["heating_curve_slope"] = self._api.getHeatingCurveSlope() - self._attributes["heating_curve_shift"] = self._api.getHeatingCurveShift() - self._attributes[ - "month_since_last_service" - ] = self._api.getMonthSinceLastService() - self._attributes["date_last_service"] = self._api.getLastServiceDate() - self._attributes["error_history"] = self._api.getErrorHistory() - self._attributes["active_error"] = self._api.getActiveError() + + with suppress(PyViCareNotSupportedFeatureError): + self._attributes[ + "heating_curve_slope" + ] = self._api.getHeatingCurveSlope() + + with suppress(PyViCareNotSupportedFeatureError): + self._attributes[ + "heating_curve_shift" + ] = self._api.getHeatingCurveShift() # Update the specific device attributes if self._heating_type == HeatingType.gas: - self._current_action = self._api.getBurnerActive() - + with suppress(PyViCareNotSupportedFeatureError): + self._current_action = self._api.getBurnerActive() elif self._heating_type == HeatingType.heatpump: - self._current_action = self._api.getCompressorActive() + with suppress(PyViCareNotSupportedFeatureError): + self._current_action = self._api.getCompressorActive() except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except ValueError: _LOGGER.error("Unable to decode data from ViCare server") diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 400618c3e85..88e9a1e4e4b 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -3,6 +3,6 @@ "name": "Viessmann ViCare", "documentation": "https://www.home-assistant.io/integrations/vicare", "codeowners": ["@oischinger"], - "requirements": ["PyViCare==0.2.5"], + "requirements": ["PyViCare==1.0.0"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 7d224de3835..4f7ab9df985 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -1,6 +1,8 @@ """Viessmann ViCare sensor device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.sensor import SensorEntity @@ -21,7 +23,6 @@ from homeassistant.const import ( from . import ( DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME, @@ -350,7 +351,7 @@ class ViCareSensor(SensorEntity): @property def available(self): """Return True if entity is available.""" - return self._state is not None and self._state != PYVICARE_ERROR + return self._state is not None @property def unique_id(self): @@ -385,8 +386,11 @@ class ViCareSensor(SensorEntity): def update(self): """Update state of sensor.""" try: - self._state = self._sensor[CONF_GETTER](self._api) + with suppress(PyViCareNotSupportedFeatureError): + self._state = self._sensor[CONF_GETTER](self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: _LOGGER.error("Unable to decode data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index cbecf7fdaf2..af373c6ee6e 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,6 +1,8 @@ """Viessmann ViCare water_heater device.""" +from contextlib import suppress import logging +from PyViCare.PyViCare import PyViCareNotSupportedFeatureError, PyViCareRateLimitError import requests from homeassistant.components.water_heater import ( @@ -9,13 +11,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS -from . import ( - DOMAIN as VICARE_DOMAIN, - PYVICARE_ERROR, - VICARE_API, - VICARE_HEATING_TYPE, - VICARE_NAME, -) +from . import DOMAIN as VICARE_DOMAIN, VICARE_API, VICARE_HEATING_TYPE, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -81,19 +77,23 @@ class ViCareWater(WaterHeaterEntity): def update(self): """Let HA know there has been an update from the ViCare API.""" try: - current_temperature = self._api.getDomesticHotWaterStorageTemperature() - if current_temperature != PYVICARE_ERROR: - self._current_temperature = current_temperature - else: - self._current_temperature = None + with suppress(PyViCareNotSupportedFeatureError): + self._current_temperature = ( + self._api.getDomesticHotWaterStorageTemperature() + ) - self._target_temperature = ( - self._api.getDomesticHotWaterConfiguredTemperature() - ) + with suppress(PyViCareNotSupportedFeatureError): + self._target_temperature = ( + self._api.getDomesticHotWaterConfiguredTemperature() + ) + + with suppress(PyViCareNotSupportedFeatureError): + self._current_mode = self._api.getActiveMode() - self._current_mode = self._api.getActiveMode() except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") + except PyViCareRateLimitError as limit_exception: + _LOGGER.error("Vicare API rate limit exceeded: %s", limit_exception) except ValueError: _LOGGER.error("Unable to decode data from ViCare server") diff --git a/homeassistant/components/vilfo/translations/de.json b/homeassistant/components/vilfo/translations/de.json index 8f20c074ff4..798410c56e3 100644 --- a/homeassistant/components/vilfo/translations/de.json +++ b/homeassistant/components/vilfo/translations/de.json @@ -1,21 +1,21 @@ { "config": { "abort": { - "already_configured": "Dieser Vilfo Router ist bereits konfiguriert." + "already_configured": "Ger\u00e4t ist bereits konfiguriert" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", - "invalid_auth": "Ung\u00fcltige Authentifizierung. Bitte \u00fcberpr\u00fcfe den Zugriffstoken und versuche es erneut.", + "invalid_auth": "Ung\u00fcltige Authentifizierung", "unknown": "Unerwarteter Fehler" }, "step": { "user": { "data": { - "access_token": "Zugriffstoken", + "access_token": "Zugangstoken", "host": "Host" }, - "description": "Richten Sie die Vilfo Router-Integration ein. Sie ben\u00f6tigen Ihren Vilfo Router-Hostnamen / Ihre IP-Adresse und ein API-Zugriffstoken. Weitere Informationen zu dieser Integration und wie Sie diese Details erhalten, finden Sie unter: https://www.home-assistant.io/integrations/vilfo", - "title": "Stellen Sie eine Verbindung zum Vilfo Router her" + "description": "Richte die Vilfo Router-Integration ein. Du ben\u00f6tigst deinen Vilfo Router-Hostnamen / deine IP-Adresse und ein API-Zugriffstoken. Weitere Informationen zu dieser Integration und wie du diese Details erh\u00e4ltst, findest du unter: https://www.home-assistant.io/integrations/vilfo", + "title": "Stelle eine Verbindung zum Vilfo Router her" } } } diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index c1aba99d84b..97d54f2e874 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -159,10 +159,10 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow): ): cv.multi_select( [ APP_HOME["name"], - *[ + *( app["name"] for app in self.hass.data[DOMAIN][CONF_APPS].data - ], + ), ] ), } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index f5071ce146a..0cb2884a8b8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -381,17 +381,17 @@ class VizioDevice(MediaPlayerEntity): # show the combination with , otherwise just return inputs if self._available_apps: return [ - *[ + *( _input for _input in self._available_inputs if _input not in INPUT_APPS - ], + ), *self._available_apps, - *[ + *( app for app in self._get_additional_app_names() if app not in self._available_apps - ], + ), ] return self._available_inputs diff --git a/homeassistant/components/vizio/translations/de.json b/homeassistant/components/vizio/translations/de.json index 28cb0d2c0b2..a47c2e0f036 100644 --- a/homeassistant/components/vizio/translations/de.json +++ b/homeassistant/components/vizio/translations/de.json @@ -8,15 +8,15 @@ "error": { "cannot_connect": "Verbindung fehlgeschlagen", "complete_pairing_failed": "Das Pairing konnte nicht abgeschlossen werden. Vergewissere dich, dass der eingegebene PIN korrekt ist und dass der Fernseher noch mit Strom versorgt wird und mit dem Netzwerk verbunden ist, bevor du es erneut versuchst.", - "existing_config_entry_found": "Ein bestehender VIZIO SmartCast-Ger\u00e4t Config-Eintrag mit der gleichen Seriennummer wurde bereits konfiguriert. Sie m\u00fcssen den vorhandenen Eintrag l\u00f6schen, um diesen zu konfigurieren." + "existing_config_entry_found": "Ein bestehender VIZIO SmartCast-Ger\u00e4t Config-Eintrag mit der gleichen Seriennummer wurde bereits konfiguriert. Du musst den vorhandenen Eintrag l\u00f6schen, um diesen zu konfigurieren." }, "step": { "pair_tv": { "data": { "pin": "PIN-Code" }, - "description": "Ihr Fernseher sollte einen Code anzeigen. Geben Sie diesen Code in das Formular ein und fahren Sie mit dem n\u00e4chsten Schritt fort, um die Kopplung abzuschlie\u00dfen.", - "title": "Schlie\u00dfen Sie den Pairing-Prozess ab" + "description": "Dein Fernseher sollte einen Code anzeigen. Gib diesen Code in das Formular ein und fahre mit dem n\u00e4chsten Schritt fort, um die Kopplung abzuschlie\u00dfen.", + "title": "Schlie\u00dfe den Pairing-Prozess ab" }, "pairing_complete": { "description": "Dein VIZIO SmartCast-Ger\u00e4t ist jetzt mit Home Assistant verbunden.", @@ -46,7 +46,7 @@ "include_or_exclude": "Apps einschlie\u00dfen oder ausschlie\u00dfen?", "volume_step": "Lautst\u00e4rken-Schrittgr\u00f6\u00dfe" }, - "description": "Wenn Sie \u00fcber ein Smart-TV-Ger\u00e4t verf\u00fcgen, k\u00f6nnen Sie Ihre Quellliste optional filtern, indem Sie ausw\u00e4hlen, welche Apps in Ihre Quellliste aufgenommen oder ausgeschlossen werden sollen.", + "description": "Wenn du \u00fcber ein Smart-TV-Ger\u00e4t verf\u00fcgst, kannst du deine Quellliste optional filtern, indem du ausw\u00e4hlst, welche Apps in deine Quellliste aufgenommen oder ausgeschlossen werden sollen.", "title": "Aktualisiere die VIZIO SmartCast-Ger\u00e4t-Optionen" } } diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 41996107ce0..c8044f6990a 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from homeassistant.const import ( CONF_ICON, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, ENERGY_KILO_WATT_HOUR, LENGTH_KILOMETERS, PERCENTAGE, @@ -30,7 +30,7 @@ CONF_SENSOR_TYPES = { CONF_ICON: "mdi:ev-station", CONF_NAME: "Max Available Power", CONF_ROUND: 0, - CONF_UNIT_OF_MEASUREMENT: ELECTRICAL_CURRENT_AMPERE, + CONF_UNIT_OF_MEASUREMENT: ELECTRIC_CURRENT_AMPERE, STATE_UNAVAILABLE: False, }, "charging_speed": { diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 63fc5d89e85..6824a1343fc 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -1,5 +1,4 @@ { - "title": "Wallbox", "config": { "step": { "user": { @@ -19,4 +18,4 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } } -} \ No newline at end of file +} diff --git a/homeassistant/components/wallbox/translations/fr.json b/homeassistant/components/wallbox/translations/fr.json new file mode 100644 index 00000000000..04428ef567f --- /dev/null +++ b/homeassistant/components/wallbox/translations/fr.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification incorrecte", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "station": "Num\u00e9ro de s\u00e9rie de la station", + "username": "Nom d'utilisateur" + } + } + } + }, + "title": "Wallbox" +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/hu.json b/homeassistant/components/wallbox/translations/hu.json index fd8db27da5e..097ba53f02e 100644 --- a/homeassistant/components/wallbox/translations/hu.json +++ b/homeassistant/components/wallbox/translations/hu.json @@ -12,9 +12,11 @@ "user": { "data": { "password": "Jelsz\u00f3", + "station": "\u00c1llom\u00e1s sorozatsz\u00e1ma", "username": "Felhaszn\u00e1l\u00f3n\u00e9v" } } } - } + }, + "title": "Wallbox" } \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/id.json b/homeassistant/components/wallbox/translations/id.json new file mode 100644 index 00000000000..8fa55e63051 --- /dev/null +++ b/homeassistant/components/wallbox/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index fcee7a446e0..85d6a791d7f 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -1,6 +1,7 @@ """Support for water heater devices.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import functools as ft import logging @@ -27,7 +28,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.util.temperature import convert as convert_temperature @@ -135,9 +136,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class WaterHeaterEntityEntityDescription(EntityDescription): + """A class that describes water heater entities.""" + + class WaterHeaterEntity(Entity): """Base class for water heater entities.""" + entity_description: WaterHeaterEntityEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None _attr_is_away_mode_on: bool | None = None diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index fe7c94ed634..8691cc4ed02 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -1,7 +1,12 @@ """Support for Waterfurnace.""" from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity -from homeassistant.const import PERCENTAGE, POWER_WATT, TEMP_FAHRENHEIT +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + PERCENTAGE, + POWER_WATT, + TEMP_FAHRENHEIT, +) from homeassistant.core import callback from homeassistant.util import slugify @@ -12,9 +17,15 @@ class WFSensorConfig: """Water Furnace Sensor configuration.""" def __init__( - self, friendly_name, field, icon="mdi:gauge", unit_of_measurement=None + self, + friendly_name, + field, + icon="mdi:gauge", + unit_of_measurement=None, + device_class=None, ): """Initialize configuration.""" + self.device_class = device_class self.friendly_name = friendly_name self.field = field self.icon = icon @@ -25,13 +36,19 @@ SENSORS = [ WFSensorConfig("Furnace Mode", "mode"), WFSensorConfig("Total Power", "totalunitpower", "mdi:flash", POWER_WATT), WFSensorConfig( - "Active Setpoint", "tstatactivesetpoint", "mdi:thermometer", TEMP_FAHRENHEIT + "Active Setpoint", + "tstatactivesetpoint", + None, + TEMP_FAHRENHEIT, + DEVICE_CLASS_TEMPERATURE, ), - WFSensorConfig("Leaving Air", "leavingairtemp", "mdi:thermometer", TEMP_FAHRENHEIT), - WFSensorConfig("Room Temp", "tstatroomtemp", "mdi:thermometer", TEMP_FAHRENHEIT), WFSensorConfig( - "Loop Temp", "enteringwatertemp", "mdi:thermometer", TEMP_FAHRENHEIT + "Leaving Air", "leavingairtemp", None, TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE ), + WFSensorConfig( + "Room Temp", "tstatroomtemp", None, TEMP_FAHRENHEIT, DEVICE_CLASS_TEMPERATURE + ), + WFSensorConfig("Loop Temp", "enteringwatertemp", None, TEMP_FAHRENHEIT), WFSensorConfig( "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", PERCENTAGE ), @@ -71,6 +88,7 @@ class WaterFurnaceSensor(SensorEntity): self._state = None self._icon = config.icon self._unit_of_measurement = config.unit_of_measurement + self._attr_device_class = config.device_class # This ensures that the sensors are isolated per waterfurnace unit self.entity_id = ENTITY_ID_FORMAT.format( diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 4fb56700f59..b0168bbb44e 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -127,7 +127,7 @@ async def async_setup_entry( if not config_entry.options: new_data = config_entry.data.copy() options = {} - for key in [ + for key in ( CONF_INCL_FILTER, CONF_EXCL_FILTER, CONF_REALTIME, @@ -136,7 +136,7 @@ async def async_setup_entry( CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_FERRIES, CONF_UNITS, - ]: + ): if key in new_data: options[key] = new_data.pop(key) elif key in defaults: diff --git a/homeassistant/components/waze_travel_time/translations/de.json b/homeassistant/components/waze_travel_time/translations/de.json index 2af713eb5d1..42ef4698151 100644 --- a/homeassistant/components/waze_travel_time/translations/de.json +++ b/homeassistant/components/waze_travel_time/translations/de.json @@ -14,7 +14,7 @@ "origin": "Startort", "region": "Region" }, - "description": "Geben Sie f\u00fcr Ursprung und Ziel die Adresse oder die GPS-Koordinaten des Standorts ein (GPS-Koordinaten m\u00fcssen durch ein Komma getrennt werden). Sie k\u00f6nnen auch eine Entity-ID eingeben, die diese Informationen in ihrem Zustand bereitstellt, eine Entity-ID mit den Attributen Breitengrad und L\u00e4ngengrad oder einen Zonen-Namen." + "description": "Gib f\u00fcr Ursprung und Ziel die Adresse oder die GPS-Koordinaten des Standorts ein (GPS-Koordinaten m\u00fcssen durch ein Komma getrennt werden). Du kannst auch eine Entity-ID eingeben, die diese Informationen in ihrem Zustand bereitstellt, eine Entity-ID mit den Attributen Breitengrad und L\u00e4ngengrad oder einen Zonen-Namen." } } }, @@ -31,7 +31,7 @@ "units": "Einheiten", "vehicle_type": "Fahrzeugtyp" }, - "description": "Mit den \"Substring\"-Eintr\u00e4gen k\u00f6nnen Sie die Integration zwingen, eine bestimmte Route zu verwenden oder eine bestimmte Route bei der Zeitreiseberechnung zu vermeiden." + "description": "Mit den \"Substring\"-Eintr\u00e4gen kannst du die Integration zwingen, eine bestimmte Route zu verwenden oder eine bestimmte Route bei der Zeitreiseberechnung zu vermeiden." } } }, diff --git a/homeassistant/components/waze_travel_time/translations/hu.json b/homeassistant/components/waze_travel_time/translations/hu.json index 94e5f96814e..401eb3c814c 100644 --- a/homeassistant/components/waze_travel_time/translations/hu.json +++ b/homeassistant/components/waze_travel_time/translations/hu.json @@ -10,9 +10,11 @@ "user": { "data": { "destination": "\u00c9rkez\u00e9s helye", + "name": "N\u00e9v", "origin": "Indul\u00e1s helye", "region": "R\u00e9gi\u00f3" - } + }, + "description": "Az Origin and Destination mez\u0151be \u00edrja be a hely c\u00edm\u00e9t vagy GPS koordin\u00e1t\u00e1it (a GPS koordin\u00e1t\u00e1kat vessz\u0151vel kell elv\u00e1lasztani). Megadhat egy entit\u00e1sazonos\u00edt\u00f3t is, amely ezeket az inform\u00e1ci\u00f3kat \u00e1llapot\u00e1ban megadja, egy entit\u00e1sazonos\u00edt\u00f3t sz\u00e9less\u00e9gi \u00e9s hossz\u00fas\u00e1gi attrib\u00fatumokkal vagy z\u00f3nabar\u00e1t nevet." } } }, @@ -23,9 +25,13 @@ "avoid_ferries": "Ker\u00fclje kompokat?", "avoid_subscription_roads": "Ker\u00fclje el az utakat, amelyekre matrica / el\u0151fizet\u00e9s sz\u00fcks\u00e9ges?", "avoid_toll_roads": "Ker\u00fclje a fizet\u0151s utakat?", + "excl_filter": "A kiv\u00e1lasztott \u00fatvonal le\u00edr\u00e1s\u00e1ban NEM szerepl\u0151 r\u00e9szl\u00e1nc", + "incl_filter": "R\u00e9szl\u00e1nc a kiv\u00e1lasztott \u00fatvonal le\u00edr\u00e1s\u00e1ban", "realtime": "Val\u00f3s idej\u0171 utaz\u00e1si id\u0151?", + "units": "Egys\u00e9gek", "vehicle_type": "J\u00e1rm\u0171 t\u00edpus" - } + }, + "description": "Az \"alfej\" bemenetek lehet\u0151v\u00e9 teszik az integr\u00e1ci\u00f3 k\u00e9nyszer\u00edt\u00e9s\u00e9t egy adott \u00fatvonal haszn\u00e1lat\u00e1ra, vagy az adott \u00fatvonal elker\u00fcl\u00e9s\u00e9re az id\u0151utaz\u00e1s kisz\u00e1m\u00edt\u00e1sakor." } } }, diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index b737129e69e..7d5f7a99d40 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,6 +1,7 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from typing import Final, TypedDict, final @@ -12,7 +13,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp @@ -97,9 +98,15 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) +@dataclass +class WeatherEntityDescription(EntityDescription): + """A class that describes weather entities.""" + + class WeatherEntity(Entity): """ABC for weather data.""" + entity_description: WeatherEntityDescription _attr_attribution: str | None = None _attr_condition: str | None _attr_forecast: list[Forecast] | None = None diff --git a/homeassistant/components/weather/translations/ar.json b/homeassistant/components/weather/translations/ar.json index c6e2e316556..aa816d118e8 100644 --- a/homeassistant/components/weather/translations/ar.json +++ b/homeassistant/components/weather/translations/ar.json @@ -1,9 +1,21 @@ { "state": { "_": { - "cloudy": "Bewolkt", - "fog": "Mist", - "sunny": "\u0645\u0634\u0645\u0633" + "clear-night": "\u0644\u064a\u0644\u0629 \u0635\u0627\u0641\u064a\u0629", + "cloudy": "\u063a\u0627\u0626\u0645", + "exceptional": "\u0627\u0633\u062a\u062b\u0646\u0627\u0626\u064a", + "fog": "\u0636\u0628\u0627\u0628", + "hail": "\u0628\u0631\u062f", + "lightning": "\u0628\u0631\u0642", + "lightning-rainy": "\u0628\u0631\u0642 \u060c \u0645\u0627\u0637\u0631", + "partlycloudy": "\u063a\u0627\u0626\u0645 \u062c\u0632\u0626\u064a\u0627", + "pouring": "\u0623\u0645\u0637\u0627\u0631 \u063a\u0632\u064a\u0631\u0629", + "rainy": "\u0645\u0627\u0637\u0631", + "snowy": "\u062b\u0644\u062c\u064a", + "snowy-rainy": "\u062b\u0644\u062c\u064a\u060c \u0645\u0645\u0637\u0631", + "sunny": "\u0645\u0634\u0645\u0633", + "windy": "\u0639\u0627\u0635\u0641", + "windy-variant": "\u0639\u0627\u0635\u0641" } } } \ No newline at end of file diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 6d61f5d62dc..8331722c397 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -1,6 +1,10 @@ """Webhooks for Home Assistant.""" +from __future__ import annotations + +from collections.abc import Awaitable import logging import secrets +from typing import Callable from aiohttp.web import Request, Response import voluptuous as vol @@ -8,7 +12,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import HTTP_OK -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.network import get_url from homeassistant.loader import bind_hass from homeassistant.util.aiohttp import MockRequest @@ -28,7 +32,13 @@ SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( @callback @bind_hass -def async_register(hass, domain, name, webhook_id, handler): +def async_register( + hass: HomeAssistant, + domain: str, + name: str, + webhook_id: str, + handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], +) -> None: """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -40,21 +50,21 @@ def async_register(hass, domain, name, webhook_id, handler): @callback @bind_hass -def async_unregister(hass, webhook_id): +def async_unregister(hass: HomeAssistant, webhook_id: str) -> None: """Remove a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) handlers.pop(webhook_id, None) @callback -def async_generate_id(): +def async_generate_id() -> str: """Generate a webhook_id.""" return secrets.token_hex(32) @callback @bind_hass -def async_generate_url(hass, webhook_id): +def async_generate_url(hass: HomeAssistant, webhook_id: str) -> str: """Generate the full URL for a webhook_id.""" return "{}{}".format( get_url(hass, prefer_external=True, allow_cloud=False), @@ -63,7 +73,7 @@ def async_generate_url(hass, webhook_id): @callback -def async_generate_path(webhook_id): +def async_generate_path(webhook_id: str) -> str: """Generate the path component for a webhook_id.""" return URL_WEBHOOK_PATH.format(webhook_id=webhook_id) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index af7f59bd266..db5a618ff5c 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -92,8 +92,8 @@ async def async_setup(hass, config): data["method"] = method["method"] async_dispatcher_send(hass, DOMAIN, data) - for service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[service]["schema"] + for service, method in SERVICE_TO_METHOD.items(): + schema = method["schema"] hass.services.async_register( DOMAIN, service, async_service_handler, schema=schema ) @@ -112,7 +112,7 @@ def convert_client_keys(config_file): return # Try to parse the file as being JSON - with open(config_file) as json_file: + with open(config_file, encoding="utf8") as json_file: try: json_conf = json.load(json_file) except (json.JSONDecodeError, UnicodeDecodeError): diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index b14fd793cab..9697f903926 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -4,6 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "requirements": ["aiopylgtv==0.4.0"], "dependencies": ["configurator"], - "codeowners": ["@bendavid"], + "codeowners": ["@bendavid", "@thecode"], "iot_class": "local_polling" } diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 52158d3f1ad..2e44a0aa0cd 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -21,6 +21,8 @@ from .const import ( # noqa: F401 ERR_UNAUTHORIZED, ERR_UNKNOWN_COMMAND, ERR_UNKNOWN_ERROR, + AsyncWebSocketCommandHandler, + WebSocketCommandHandler, ) from .decorators import ( # noqa: F401 async_response, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 179fbcd1a30..fa8286084b6 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -268,7 +268,7 @@ async def handle_manifest_list( """Handle integrations command.""" loaded_integrations = async_get_loaded_integrations(hass) integrations = await asyncio.gather( - *[async_get_integration(hass, domain) for domain in loaded_integrations] + *(async_get_integration(hass, domain) for domain in loaded_integrations) ) connection.send_result( msg["id"], [integration.manifest for integration in integrations] diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 9e9aa5ee278..aa7b5ff05c1 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -10,6 +10,7 @@ from homeassistant import config_entries from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, EVENT_HOMEASSISTANT_STOP @@ -28,17 +29,17 @@ MAX_CONCURRENCY = 3 # Mapping from Wemo model_name to domain. WEMO_MODEL_DISPATCH = { - "Bridge": LIGHT_DOMAIN, - "CoffeeMaker": SWITCH_DOMAIN, - "Dimmer": LIGHT_DOMAIN, - "Humidifier": FAN_DOMAIN, - "Insight": SWITCH_DOMAIN, - "LightSwitch": SWITCH_DOMAIN, - "Maker": SWITCH_DOMAIN, - "Motion": BINARY_SENSOR_DOMAIN, - "OutdoorPlug": SWITCH_DOMAIN, - "Sensor": BINARY_SENSOR_DOMAIN, - "Socket": SWITCH_DOMAIN, + "Bridge": [LIGHT_DOMAIN], + "CoffeeMaker": [SWITCH_DOMAIN], + "Dimmer": [LIGHT_DOMAIN], + "Humidifier": [FAN_DOMAIN], + "Insight": [SENSOR_DOMAIN, SWITCH_DOMAIN], + "LightSwitch": [SWITCH_DOMAIN], + "Maker": [SWITCH_DOMAIN], + "Motion": [BINARY_SENSOR_DOMAIN], + "OutdoorPlug": [SWITCH_DOMAIN], + "Sensor": [BINARY_SENSOR_DOMAIN], + "Socket": [SWITCH_DOMAIN], } _LOGGER = logging.getLogger(__name__) @@ -151,32 +152,31 @@ class WemoDispatcher: if wemo.serialnumber in self._added_serial_numbers: return - component = WEMO_MODEL_DISPATCH.get(wemo.model_name, SWITCH_DOMAIN) device = await async_register_device(hass, self._config_entry, wemo) + for component in WEMO_MODEL_DISPATCH.get(wemo.model_name, [SWITCH_DOMAIN]): + # Three cases: + # - First time we see component, we need to load it and initialize the backlog + # - Component is being loaded, add to backlog + # - Component is loaded, backlog is gone, dispatch discovery - # Three cases: - # - First time we see component, we need to load it and initialize the backlog - # - Component is being loaded, add to backlog - # - Component is loaded, backlog is gone, dispatch discovery - - if component not in self._loaded_components: - hass.data[DOMAIN]["pending"][component] = [device] - self._loaded_components.add(component) - hass.async_create_task( - hass.config_entries.async_forward_entry_setup( - self._config_entry, component + if component not in self._loaded_components: + hass.data[DOMAIN]["pending"][component] = [device] + self._loaded_components.add(component) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + self._config_entry, component + ) ) - ) - elif component in hass.data[DOMAIN]["pending"]: - hass.data[DOMAIN]["pending"][component].append(device) + elif component in hass.data[DOMAIN]["pending"]: + hass.data[DOMAIN]["pending"][component].append(device) - else: - async_dispatcher_send( - hass, - f"{DOMAIN}.{component}", - device, - ) + else: + async_dispatcher_send( + hass, + f"{DOMAIN}.{component}", + device, + ) self._added_serial_numbers.add(wemo.serialnumber) @@ -235,12 +235,12 @@ class WemoDiscovery: _LOGGER.debug("Adding statically configured WeMo devices") for device in await gather_with_concurrency( MAX_CONCURRENCY, - *[ + *( self._hass.async_add_executor_job( validate_static_config, host, port ) for host, port in self._static_config - ], + ), ): if device: await self._wemo_dispatcher.async_add_unique_device( diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py index 94d5a587c17..f3ba5e0ec52 100644 --- a/homeassistant/components/wemo/binary_sensor.py +++ b/homeassistant/components/wemo/binary_sensor.py @@ -21,10 +21,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.binary_sensor", _discovered_wemo) await asyncio.gather( - *[ + *( _discovered_wemo(device) for device in hass.data[WEMO_DOMAIN]["pending"].pop("binary_sensor") - ] + ) ) diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 6910a4c8536..1582a0110cd 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -75,10 +75,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.fan", _discovered_wemo) await asyncio.gather( - *[ + *( _discovered_wemo(device) for device in hass.data[WEMO_DOMAIN]["pending"].pop("fan") - ] + ) ) platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py index 79f2e9b7172..0767c6b6603 100644 --- a/homeassistant/components/wemo/light.py +++ b/homeassistant/components/wemo/light.py @@ -50,10 +50,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.light", _discovered_wemo) await asyncio.gather( - *[ + *( _discovered_wemo(device) for device in hass.data[WEMO_DOMAIN]["pending"].pop("light") - ] + ) ) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 3d051fcc6dc..21a7760741a 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.5"], + "requirements": ["pywemo==0.6.6"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py new file mode 100644 index 00000000000..ebd68231e0c --- /dev/null +++ b/homeassistant/components/wemo/sensor.py @@ -0,0 +1,131 @@ +"""Support for power sensors in WeMo Insight devices.""" +import asyncio +from datetime import datetime, timedelta +from typing import Callable + +from homeassistant.components.sensor import ( + STATE_CLASS_MEASUREMENT, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import StateType +from homeassistant.util import Throttle, convert, dt + +from .const import DOMAIN as WEMO_DOMAIN +from .entity import WemoSubscriptionEntity +from .wemo_device import DeviceWrapper + +SCAN_INTERVAL = timedelta(seconds=10) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up WeMo sensors.""" + + async def _discovered_wemo(device: DeviceWrapper): + """Handle a discovered Wemo device.""" + + @Throttle(SCAN_INTERVAL) + def update_insight_params(): + device.wemo.update_insight_params() + + async_add_entities( + [ + InsightCurrentPower(device, update_insight_params), + InsightTodayEnergy(device, update_insight_params), + ] + ) + + async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.sensor", _discovered_wemo) + + await asyncio.gather( + *( + _discovered_wemo(device) + for device in hass.data[WEMO_DOMAIN]["pending"].pop("sensor") + ) + ) + + +class InsightSensor(WemoSubscriptionEntity, SensorEntity): + """Common base for WeMo Insight power sensors.""" + + _name_suffix: str + + def __init__(self, device: DeviceWrapper, update_insight_params: Callable) -> None: + """Initialize the WeMo Insight power sensor.""" + super().__init__(device) + self._update_insight_params = update_insight_params + + @property + def name(self) -> str: + """Return the name of the entity if any.""" + return f"{super().name} {self.entity_description.name}" + + @property + def unique_id(self) -> str: + """Return the id of this entity.""" + return f"{super().unique_id}_{self.entity_description.key}" + + @property + def available(self) -> str: + """Return true if sensor is available.""" + return ( + self.entity_description.key in self.wemo.insight_params + and super().available + ) + + def _update(self, force_update=True) -> None: + with self._wemo_exception_handler("update status"): + if force_update or not self.wemo.insight_params: + self._update_insight_params() + + +class InsightCurrentPower(InsightSensor): + """Current instantaineous power consumption.""" + + entity_description = SensorEntityDescription( + key="currentpower", + name="Current Power", + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=POWER_WATT, + ) + + @property + def state(self) -> StateType: + """Return the current power consumption.""" + return ( + convert(self.wemo.insight_params[self.entity_description.key], float, 0.0) + / 1000.0 + ) + + +class InsightTodayEnergy(InsightSensor): + """Energy used today.""" + + entity_description = SensorEntityDescription( + key="todaymw", + name="Today Energy", + device_class=DEVICE_CLASS_ENERGY, + state_class=STATE_CLASS_MEASUREMENT, + unit_of_measurement=ENERGY_KILO_WATT_HOUR, + ) + + @property + def last_reset(self) -> datetime: + """Return the time when the sensor was initialized.""" + return dt.start_of_local_day() + + @property + def state(self) -> StateType: + """Return the current energy use today.""" + miliwatts = convert( + self.wemo.insight_params[self.entity_description.key], float, 0.0 + ) + return round(miliwatts / (1000.0 * 1000.0 * 60), 2) diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py index 5e97031786c..a7031d669a4 100644 --- a/homeassistant/components/wemo/switch.py +++ b/homeassistant/components/wemo/switch.py @@ -40,10 +40,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, f"{WEMO_DOMAIN}.switch", _discovered_wemo) await asyncio.gather( - *[ + *( _discovered_wemo(device) for device in hass.data[WEMO_DOMAIN]["pending"].pop("switch") - ] + ) ) diff --git a/homeassistant/components/wemo/translations/de.json b/homeassistant/components/wemo/translations/de.json index b0735db1249..debbb129459 100644 --- a/homeassistant/components/wemo/translations/de.json +++ b/homeassistant/components/wemo/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "no_devices_found": "Es wurden keine Wemo-Ger\u00e4te im Netzwerk gefunden.", + "no_devices_found": "Keine Ger\u00e4te im Netzwerk gefunden", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "step": { diff --git a/homeassistant/components/wemo/translations/fr.json b/homeassistant/components/wemo/translations/fr.json index ccf2ac6ef21..e0372147f58 100644 --- a/homeassistant/components/wemo/translations/fr.json +++ b/homeassistant/components/wemo/translations/fr.json @@ -9,5 +9,10 @@ "description": "Voulez-vous configurer Wemo?" } } + }, + "device_automation": { + "trigger_type": { + "long_press": "Le bouton Wemo a \u00e9t\u00e9 enfonc\u00e9 pendant 2 secondes" + } } } \ No newline at end of file diff --git a/homeassistant/components/wemo/translations/hu.json b/homeassistant/components/wemo/translations/hu.json index ab799e90c74..bcb2f438353 100644 --- a/homeassistant/components/wemo/translations/hu.json +++ b/homeassistant/components/wemo/translations/hu.json @@ -4,5 +4,10 @@ "no_devices_found": "Nem tal\u00e1lhat\u00f3 eszk\u00f6z a h\u00e1l\u00f3zaton", "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." } + }, + "device_automation": { + "trigger_type": { + "long_press": "A Wemo gombot 2 m\u00e1sodpercig nyomva tartott\u00e1k." + } } } \ No newline at end of file diff --git a/homeassistant/components/wiffi/translations/de.json b/homeassistant/components/wiffi/translations/de.json index 4084cda8f9f..c94122cac5e 100644 --- a/homeassistant/components/wiffi/translations/de.json +++ b/homeassistant/components/wiffi/translations/de.json @@ -7,7 +7,7 @@ "step": { "user": { "data": { - "port": "Server Port" + "port": "Port" }, "title": "TCP-Server f\u00fcr WIFFI-Ger\u00e4te einrichten" } diff --git a/homeassistant/components/wilight/translations/ar.json b/homeassistant/components/wilight/translations/ar.json new file mode 100644 index 00000000000..033cc81d349 --- /dev/null +++ b/homeassistant/components/wilight/translations/ar.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_supported_device": "\u0647\u0630\u0627 WiLight \u063a\u064a\u0631 \u0645\u062f\u0639\u0648\u0645 \u062d\u0627\u0644\u064a\u0627", + "not_wilight_device": "\u0647\u0630\u0627 \u0627\u0644\u062c\u0647\u0627\u0632 \u0644\u064a\u0633 WiLight" + }, + "flow_title": "{name}", + "step": { + "confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0625\u0639\u062f\u0627\u062f WiLight {name} \u061f \n\n \u064a\u062f\u0639\u0645: {components}", + "title": "WiLight" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wilight/translations/de.json b/homeassistant/components/wilight/translations/de.json index 851defe0e32..546f8cec7b5 100644 --- a/homeassistant/components/wilight/translations/de.json +++ b/homeassistant/components/wilight/translations/de.json @@ -8,7 +8,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "M\u00f6chten Sie WiLight {name} einrichten? \n\n Es unterst\u00fctzt: {components}", + "description": "M\u00f6chtest du WiLight {name} einrichten? \n\n Es unterst\u00fctzt: {components}", "title": "WiLight" } } diff --git a/homeassistant/components/wilight/translations/hu.json b/homeassistant/components/wilight/translations/hu.json index 4ddb08bb975..3b769a88b8f 100644 --- a/homeassistant/components/wilight/translations/hu.json +++ b/homeassistant/components/wilight/translations/hu.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "not_supported_device": "Ez a WiLight jelenleg nem t\u00e1mogatott", + "not_wilight_device": "Ez az eszk\u00f6z nem WiLight eszk\u00f6z" }, "flow_title": "{name}", "step": { "confirm": { + "description": "Be szeretn\u00e9 \u00e1ll\u00edtani a WiLight {name} ? \n\n T\u00e1mogatja: {components}", "title": "WiLight" } } diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index 5da19f54dcf..519663d1261 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -3,17 +3,16 @@ import logging from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from wirelesstagpy import NotificationConfig as NC, WirelessTags, WirelessTagsException +from wirelesstagpy import WirelessTags, WirelessTagsException -from homeassistant import util from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_PASSWORD, CONF_USERNAME, + ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - VOLT, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send @@ -67,11 +66,6 @@ class WirelessTagPlatform: self.tags = {} self._local_base_url = None - @property - def tag_manager_macs(self): - """Return list of tag managers mac addresses in user account.""" - return self.api.mac_addresses - def load_tags(self): """Load tags from remote server.""" self.tags = self.api.load_tags() @@ -91,97 +85,44 @@ class WirelessTagPlatform: if disarm_func is not None: disarm_func(switch.tag_id, switch.tag_manager_mac) - def make_notifications(self, binary_sensors, mac): - """Create configurations for push notifications.""" - _LOGGER.info("Creating configurations for push notifications") - configs = [] + def start_monitoring(self): + """Start monitoring push events.""" - bi_url = self.binary_event_callback_url - for bi_sensor in binary_sensors: - configs.extend(bi_sensor.event.build_notifications(bi_url, mac)) - - update_url = self.update_callback_url - - update_config = NC.make_config_for_update_event(update_url, mac) - - configs.append(update_config) - return configs - - def install_push_notifications(self, binary_sensors): - """Register local push notification from tag manager.""" - _LOGGER.info("Registering local push notifications") - for mac in self.tag_manager_macs: - configs = self.make_notifications(binary_sensors, mac) - # install notifications for all tags in tag manager - # specified by mac - result = self.api.install_push_notification(0, configs, True, mac) - if not result: - self.hass.components.persistent_notification.create( - "Error: failed to install local push notifications
", - title="Wireless Sensor Tag Setup Local Push Notifications", - notification_id="wirelesstag_failed_push_notification", - ) - else: - _LOGGER.info( - "Installed push notifications for all tags in %s", - mac, - ) - - @property - def local_base_url(self): - """Define base url of hass in local network.""" - if self._local_base_url is None: - self._local_base_url = f"http://{util.get_local_ip()}" - - port = self.hass.config.api.port - if port is not None: - self._local_base_url += f":{port}" - return self._local_base_url - - @property - def update_callback_url(self): - """Return url for local push notifications(update event).""" - return f"{self.local_base_url}/api/events/wirelesstag_update_tags" - - @property - def binary_event_callback_url(self): - """Return url for local push notifications(binary event).""" - return f"{self.local_base_url}/api/events/wirelesstag_binary_event" - - def handle_update_tags_event(self, event): - """Handle push event from wireless tag manager.""" - _LOGGER.info("Push notification for update arrived: %s", event) - try: - tag_id = event.data.get("id") - mac = event.data.get("mac") - dispatcher_send(self.hass, SIGNAL_TAG_UPDATE.format(tag_id, mac), event) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unable to handle tag update event:\ - %s error: %s", - str(event), - str(ex), + def push_callback(tags_spec, event_spec): + """Handle push update.""" + _LOGGER.debug( + "Push notification arrived: %s, events: %s", tags_spec, event_spec ) + for uuid, tag in tags_spec.items(): + try: + tag_id = tag.tag_id + mac = tag.tag_manager_mac + _LOGGER.debug("Push notification for tag update arrived: %s", tag) + dispatcher_send( + self.hass, SIGNAL_TAG_UPDATE.format(tag_id, mac), tag + ) + if uuid in event_spec: + events = event_spec[uuid] + for event in events: + _LOGGER.debug( + "Push notification for binary event arrived: %s", event + ) + dispatcher_send( + self.hass, + SIGNAL_BINARY_EVENT_UPDATE.format( + tag_id, event.type, mac + ), + tag, + ) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error( + "Unable to handle tag update:\ + %s error: %s", + str(tag), + str(ex), + ) - def handle_binary_event(self, event): - """Handle push notifications for binary (on/off) events.""" - _LOGGER.info("Push notification for binary event arrived: %s", event) - try: - tag_id = event.data.get("id") - event_type = event.data.get("type") - mac = event.data.get("mac") - dispatcher_send( - self.hass, - SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac), - event, - ) - except Exception as ex: # pylint: disable=broad-except - _LOGGER.error( - "Unable to handle tag binary event:\ - %s error: %s", - str(event), - str(ex), - ) + self.api.start_monitoring(push_callback) def setup(hass, config): @@ -195,6 +136,7 @@ def setup(hass, config): platform = WirelessTagPlatform(hass, wirelesstags) platform.load_tags() + platform.start_monitoring() hass.data[DOMAIN] = platform except (ConnectTimeout, HTTPError, WirelessTagsException) as ex: _LOGGER.error("Unable to connect to wirelesstag.net service: %s", str(ex)) @@ -205,12 +147,6 @@ def setup(hass, config): ) return False - # listen to custom events - hass.bus.listen( - "wirelesstag_update_tags", hass.data[DOMAIN].handle_update_tags_event - ) - hass.bus.listen("wirelesstag_binary_event", hass.data[DOMAIN].handle_binary_event) - return True @@ -276,7 +212,7 @@ class WirelessTagBaseSensor(Entity): """Return the state attributes.""" return { ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining * 100), - ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}{VOLT}", + ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}{ELECTRIC_POTENTIAL_VOLT}", ATTR_TAG_SIGNAL_STRENGTH: f"{self._tag.signal_strength}{SIGNAL_STRENGTH_DECIBELS_MILLIWATT}", ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{PERCENTAGE}", diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py index ef97867e829..da901f31cd6 100644 --- a/homeassistant/components/wirelesstag/binary_sensor.py +++ b/homeassistant/components/wirelesstag/binary_sensor.py @@ -81,7 +81,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors.append(WirelessTagBinarySensor(platform, tag, sensor_type)) add_entities(sensors, True) - hass.add_job(platform.install_push_notifications, sensors) class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): @@ -134,8 +133,8 @@ class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorEntity): return self.principal_value @callback - def _on_binary_event_callback(self, event): + def _on_binary_event_callback(self, new_tag): """Update state from arrived push notification.""" - # state should be 'on' or 'off' - self._state = event.data.get("state") + self._tag = new_tag + self._state = self.updated_state_value() self.async_write_ha_state() diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json index fd18235c994..37c1b82cba9 100644 --- a/homeassistant/components/wirelesstag/manifest.json +++ b/homeassistant/components/wirelesstag/manifest.json @@ -2,7 +2,7 @@ "domain": "wirelesstag", "name": "Wireless Sensor Tags", "documentation": "https://www.home-assistant.io/integrations/wirelesstag", - "requirements": ["wirelesstagpy==0.4.1"], - "codeowners": [], - "iot_class": "local_push" + "requirements": ["wirelesstagpy==0.5.0"], + "codeowners": ["@sergeymaysak"], + "iot_class": "cloud_push" } diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py index cc0ce0cb888..de70efda424 100644 --- a/homeassistant/components/wirelesstag/sensor.py +++ b/homeassistant/components/wirelesstag/sensor.py @@ -108,9 +108,9 @@ class WirelessTagSensor(WirelessTagBaseSensor, SensorEntity): return self._tag.sensor[self._sensor_type] @callback - def _update_tag_info_callback(self, event): + def _update_tag_info_callback(self, new_tag): """Handle push notification sent by tag manager.""" - _LOGGER.debug("Entity to update state: %s event data: %s", self, event.data) - new_value = self._sensor.value_from_update_event(event.data) - self._state = self.decorate_value(new_value) + _LOGGER.debug("Entity to update state: %s with new tag: %s", self, new_tag) + self._tag = new_tag + self._state = self.updated_state_value() self.async_write_ha_state() diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index a187786c995..646243e309d 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -673,21 +673,17 @@ class DataManager: response = await self._hass.async_add_executor_job(self._api.notify_list) subscribed_applis = frozenset( - [ - profile.appli - for profile in response.profiles - if profile.callbackurl == self._webhook_config.url - ] + profile.appli + for profile in response.profiles + if profile.callbackurl == self._webhook_config.url ) # Determine what subscriptions need to be created. ignored_applis = frozenset({NotifyAppli.USER, NotifyAppli.UNKNOWN}) to_add_applis = frozenset( - [ - appli - for appli in NotifyAppli - if appli not in subscribed_applis and appli not in ignored_applis - ] + appli + for appli in NotifyAppli + if appli not in subscribed_applis and appli not in ignored_applis ) # Subscribe to each one. diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index d88f4e38c6a..dd744152bb1 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,7 +1,7 @@ """Constants used by the Withings component.""" from enum import Enum -import homeassistant.const as const +from homeassistant import const CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index d80dbf16a60..180ef89c1b7 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -21,7 +21,6 @@ ATTR_LED_COUNT = "led_count" ATTR_MAX_POWER = "max_power" ATTR_ON = "on" ATTR_PALETTE = "palette" -ATTR_PLAYLIST = "playlist" ATTR_PRESET = "preset" ATTR_REVERSE = "reverse" ATTR_SEGMENT_ID = "segment_id" @@ -30,9 +29,6 @@ ATTR_SPEED = "speed" ATTR_TARGET_BRIGHTNESS = "target_brightness" ATTR_UDP_PORT = "udp_port" -# Units of measurement -CURRENT_MA = "mA" - # Services SERVICE_EFFECT = "effect" SERVICE_PRESET = "preset" diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 4326f1066c7..2081208e398 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -29,12 +29,12 @@ from .const import ( ATTR_INTENSITY, ATTR_ON, ATTR_PALETTE, - ATTR_PLAYLIST, ATTR_PRESET, ATTR_REVERSE, ATTR_SEGMENT_ID, ATTR_SPEED, DOMAIN, + LOGGER, SERVICE_EFFECT, SERVICE_PRESET, ) @@ -163,6 +163,13 @@ class WLEDMasterLight(WLEDEntity, LightEntity): preset: int, ) -> None: """Set a WLED light to a saved preset.""" + # The WLED preset service is replaced by a preset select entity + # and marked deprecated as of Home Assistant 2021.8 + LOGGER.warning( + "The 'wled.preset' service is deprecated and replaced by a " + "dedicated preset select entity; Please use that entity to " + "change presets instead" + ) await self.coordinator.wled.preset(preset=preset) @@ -212,15 +219,10 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the entity.""" - playlist: int | None = self.coordinator.data.state.playlist - if playlist == -1: - playlist = None - segment = self.coordinator.data.state.segments[self._segment] return { ATTR_INTENSITY: segment.intensity, ATTR_PALETTE: segment.palette.name, - ATTR_PLAYLIST: playlist, ATTR_REVERSE: segment.reverse, ATTR_SPEED: segment.speed, } diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 348109f6b87..5ece2d4b9d8 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -3,7 +3,7 @@ "name": "WLED", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", - "requirements": ["wled==0.7.1"], + "requirements": ["wled==0.8.0"], "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 373565b7ef7..f8473a8f26d 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import partial -from wled import Preset +from wled import Playlist, Preset from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry @@ -26,7 +26,7 @@ async def async_setup_entry( """Set up WLED select based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WLEDPresetSelect(coordinator)]) + async_add_entities([WLEDPlaylistSelect(coordinator), WLEDPresetSelect(coordinator)]) update_segments = partial( async_update_segments, @@ -69,6 +69,39 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): await self.coordinator.wled.preset(preset=option) +class WLEDPlaylistSelect(WLEDEntity, SelectEntity): + """Define a WLED Playlist select.""" + + _attr_icon = "mdi:play-speed" + + def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: + """Initialize WLED playlist.""" + super().__init__(coordinator=coordinator) + + self._attr_name = f"{coordinator.data.info.name} Playlist" + self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist" + self._attr_options = [ + playlist.name for playlist in self.coordinator.data.playlists + ] + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return len(self.coordinator.data.playlists) > 0 and super().available + + @property + def current_option(self) -> str | None: + """Return the currently selected playlist.""" + if not isinstance(self.coordinator.data.state.playlist, Playlist): + return None + return self.coordinator.data.state.playlist.name + + @wled_exception_handler + async def async_select_option(self, option: str) -> None: + """Set WLED segment to the selected playlist.""" + await self.coordinator.wled.playlist(playlist=option) + + class WLEDPaletteSelect(WLEDEntity, SelectEntity): """Defines a WLED Palette select.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 37311e333c3..634f903c020 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DATA_BYTES, DEVICE_CLASS_SIGNAL_STRENGTH, DEVICE_CLASS_TIMESTAMP, + ELECTRIC_CURRENT_MILLIAMPERE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow -from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, CURRENT_MA, DOMAIN +from .const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN from .coordinator import WLEDDataUpdateCoordinator from .models import WLEDEntity @@ -47,7 +48,7 @@ class WLEDEstimatedCurrentSensor(WLEDEntity, SensorEntity): """Defines a WLED estimated current sensor.""" _attr_icon = "mdi:power" - _attr_unit_of_measurement = CURRENT_MA + _attr_unit_of_measurement = ELECTRIC_CURRENT_MILLIAMPERE _attr_device_class = DEVICE_CLASS_CURRENT def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: diff --git a/homeassistant/components/wled/services.yaml b/homeassistant/components/wled/services.yaml index f8d636686be..9ca73fac0a3 100644 --- a/homeassistant/components/wled/services.yaml +++ b/homeassistant/components/wled/services.yaml @@ -34,14 +34,13 @@ effect: max: 255 reverse: name: Reverse effect - description: - Reverse the effect. Either true to reverse or false otherwise. + description: Reverse the effect. Either true to reverse or false otherwise. default: false selector: boolean: preset: - name: Set preset + name: Set preset (deprecated) description: Set a preset for the WLED device. target: entity: diff --git a/homeassistant/components/wled/translations/ar.json b/homeassistant/components/wled/translations/ar.json new file mode 100644 index 00000000000..65a38b0c21d --- /dev/null +++ b/homeassistant/components/wled/translations/ar.json @@ -0,0 +1,11 @@ +{ + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "\u062d\u0627\u0641\u0638 \u0639\u0644\u0649 \u0627\u0644\u0636\u0648\u0621 \u0627\u0644\u0631\u0626\u064a\u0633\u064a\u060c \u062d\u062a\u0649 \u0645\u0639 \u0642\u0637\u0639\u0629 LED \u0648\u0627\u062d\u062f\u0629." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wled/translations/de.json b/homeassistant/components/wled/translations/de.json index d03ef92d041..01b0839ba32 100644 --- a/homeassistant/components/wled/translations/de.json +++ b/homeassistant/components/wled/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Dieses WLED-Ger\u00e4t ist bereits konfiguriert.", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "cannot_connect": "Verbindung fehlgeschlagen" }, "error": { @@ -13,10 +13,10 @@ "data": { "host": "Host" }, - "description": "Richten Sie Ihren WLED f\u00fcr die Integration mit Home Assistant ein." + "description": "Richte deinen WLED f\u00fcr die Integration mit Home Assistant ein." }, "zeroconf_confirm": { - "description": "M\u00f6chten Sie die WLED mit dem Namen `{name}` zu Home Assistant hinzuf\u00fcgen?", + "description": "M\u00f6chtest du die WLED mit dem Namen `{name}` zu Home Assistant hinzuf\u00fcgen?", "title": "WLED-Ger\u00e4t entdeckt" } } diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json index 137decb7f40..dec038a8a92 100644 --- a/homeassistant/components/wled/translations/fr.json +++ b/homeassistant/components/wled/translations/fr.json @@ -20,5 +20,14 @@ "title": "Dispositif WLED d\u00e9couvert" } } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "Garder la lumi\u00e8re principale, m\u00eame avec 1 segment LED." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/wled/translations/hu.json b/homeassistant/components/wled/translations/hu.json index 0d2c85e477d..769573bfc89 100644 --- a/homeassistant/components/wled/translations/hu.json +++ b/homeassistant/components/wled/translations/hu.json @@ -20,5 +20,14 @@ "title": "Felfedezett WLED eszk\u00f6z" } } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "Tartsa a f\u0151f\u00e9nyt, m\u00e9g 1 LED szegmenssel is." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/wled/translations/pl.json b/homeassistant/components/wled/translations/pl.json index 423c30d1fe8..c4a2efc43a1 100644 --- a/homeassistant/components/wled/translations/pl.json +++ b/homeassistant/components/wled/translations/pl.json @@ -20,5 +20,14 @@ "title": "Wykryto urz\u0105dzenie WLED" } } + }, + "options": { + "step": { + "init": { + "data": { + "keep_master_light": "Utw\u00f3rz encj\u0119 \"master light\", nawet z 1 segmentem LED." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.ar.json b/homeassistant/components/wolflink/translations/sensor.ar.json new file mode 100644 index 00000000000..dd850446012 --- /dev/null +++ b/homeassistant/components/wolflink/translations/sensor.ar.json @@ -0,0 +1,7 @@ +{ + "state": { + "wolflink__state": { + "bereit_keine_ladung": "\u062c\u0627\u0647\u0632 \u060c \u0644\u0627 \u064a\u062a\u0645 \u0627\u0644\u062a\u062d\u0645\u064a\u0644" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wolflink/translations/sensor.de.json b/homeassistant/components/wolflink/translations/sensor.de.json index 6b1baf8b0bf..497f559a9de 100644 --- a/homeassistant/components/wolflink/translations/sensor.de.json +++ b/homeassistant/components/wolflink/translations/sensor.de.json @@ -10,7 +10,7 @@ "at_abschaltung": "AT Abschaltung", "at_frostschutz": "AT Frostschutz", "aus": "Aus", - "auto": "", + "auto": "Automatisch", "auto_off_cool": "AutoOffCool", "auto_on_cool": "AutoOnCool", "automatik_aus": "Automatik AUS", diff --git a/homeassistant/components/wolflink/translations/sensor.he.json b/homeassistant/components/wolflink/translations/sensor.he.json index 68b635ba82b..8447fd66b31 100644 --- a/homeassistant/components/wolflink/translations/sensor.he.json +++ b/homeassistant/components/wolflink/translations/sensor.he.json @@ -1,6 +1,7 @@ { "state": { "wolflink__state": { + "permanent": "\u05e7\u05d1\u05d5\u05e2", "solarbetrieb": "\u05de\u05e6\u05d1 \u05e1\u05d5\u05dc\u05d0\u05e8\u05d9", "standby": "\u05de\u05e6\u05d1 \u05d4\u05de\u05ea\u05e0\u05d4", "start": "\u05d4\u05ea\u05d7\u05dc", diff --git a/homeassistant/components/wolflink/translations/sensor.hu.json b/homeassistant/components/wolflink/translations/sensor.hu.json index 2d8cdda9315..b393660f35a 100644 --- a/homeassistant/components/wolflink/translations/sensor.hu.json +++ b/homeassistant/components/wolflink/translations/sensor.hu.json @@ -1,6 +1,8 @@ { "state": { "wolflink__state": { + "auto_off_cool": "AutomataKiH\u0171t\u00e9s", + "automatik_aus": "Automatikus kikapcsol\u00e1s", "permanent": "\u00c1lland\u00f3" } } diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index ed3822b9698..fc726d56f04 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity from homeassistant.const import CONF_NAME, WEEKDAYS import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,20 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Remove holidays try: for date in remove_holidays: - obj_holidays.pop(date) + try: + # is this formatted as a date? + if dt.parse_date(date): + # remove holiday by date + removed = obj_holidays.pop(date) + _LOGGER.debug("Removed %s", date) + else: + # remove holiday by name + _LOGGER.debug("Treating '%s' as named holiday", date) + removed = obj_holidays.pop_named(date) + for holiday in removed: + _LOGGER.debug("Removed %s by name '%s'", holiday, date) + except KeyError as unmatched: + _LOGGER.warning("No holiday found matching %s", unmatched) except TypeError: _LOGGER.debug("No holidays to remove or invalid holidays") diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 6fc8d2328a1..f43003738df 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.11.1"], + "requirements": ["holidays==0.11.2"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling" diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 8b45326cdbd..153d496a7d6 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -122,12 +122,12 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): """Return other details about the sensor state.""" if self._data is not None: attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} - for key in [ + for key in ( ATTR_AVG_TIME, ATTR_NAME, ATTR_DESCRIPTION, ATTR_TRAVEL_TIME_ID, - ]: + ): attrs[key] = self._data.get(key) attrs[ATTR_TIME_UPDATED] = _parse_wsdot_timestamp( self._data.get(ATTR_TIME_UPDATED) diff --git a/homeassistant/components/wunderground/__init__.py b/homeassistant/components/wunderground/__init__.py deleted file mode 100644 index faed41fdbea..00000000000 --- a/homeassistant/components/wunderground/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The wunderground component.""" diff --git a/homeassistant/components/wunderground/manifest.json b/homeassistant/components/wunderground/manifest.json deleted file mode 100644 index b932d9ac403..00000000000 --- a/homeassistant/components/wunderground/manifest.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "domain": "wunderground", - "name": "Weather Underground (WUnderground)", - "documentation": "https://www.home-assistant.io/integrations/wunderground", - "codeowners": [], - "iot_class": "cloud_polling" -} diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py deleted file mode 100644 index 887e2264a70..00000000000 --- a/homeassistant/components/wunderground/sensor.py +++ /dev/null @@ -1,1282 +0,0 @@ -"""Support for WUnderground weather service.""" -from __future__ import annotations - -import asyncio -from datetime import timedelta -import logging -import re -from typing import Any, Callable - -import aiohttp -import async_timeout -import voluptuous as vol - -from homeassistant.components import sensor -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - DEGREE, - IRRADIATION_WATTS_PER_SQUARE_METER, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - PERCENTAGE, - PRESSURE_INHG, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_RESOURCE = "http://api.wunderground.com/api/{}/{}/{}/q/" -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by the WUnderground weather service" - -CONF_PWS_ID = "pws_id" -CONF_LANG = "lang" - -DEFAULT_LANG = "EN" - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - - -# Helper classes for declaring sensor configurations - - -class WUSensorConfig: - """WU Sensor Configuration. - - defines basic HA properties of the weather sensor and - stores callbacks that can parse sensor values out of - the json data received by WU API. - """ - - def __init__( - self, - friendly_name: str | Callable, - feature: str, - value: Callable[[WUndergroundData], Any], - unit_of_measurement: str | None = None, - entity_picture=None, - icon: str = "mdi:gauge", - extra_state_attributes=None, - device_class=None, - ) -> None: - """Initialize sensor configuration. - - :param friendly_name: Friendly name - :param feature: WU feature. See: - https://www.wunderground.com/weather/api/d/docs?d=data/index - :param value: callback that extracts desired value from WUndergroundData object - :param unit_of_measurement: unit of measurement - :param entity_picture: value or callback returning URL of entity picture - :param icon: icon name or URL - :param extra_state_attributes: dictionary of attributes, or callable that returns it - """ - self.friendly_name = friendly_name - self.unit_of_measurement = unit_of_measurement - self.feature = feature - self.value = value - self.entity_picture = entity_picture - self.icon = icon - self.extra_state_attributes = extra_state_attributes or {} - self.device_class = device_class - - -class WUCurrentConditionsSensorConfig(WUSensorConfig): - """Helper for defining sensor configurations for current conditions.""" - - def __init__( - self, - friendly_name: str | Callable, - field: str, - icon: str | None = "mdi:gauge", - unit_of_measurement: str | None = None, - device_class=None, - ) -> None: - """Initialize current conditions sensor configuration. - - :param friendly_name: Friendly name of sensor - :field: Field name in the "current_observation" dictionary. - :icon: icon name or URL, if None sensor will use current weather symbol - :unit_of_measurement: unit of measurement - """ - super().__init__( - friendly_name, - "conditions", - value=lambda wu: wu.data["current_observation"][field], - icon=icon, - unit_of_measurement=unit_of_measurement, - entity_picture=lambda wu: wu.data["current_observation"]["icon_url"] - if icon is None - else None, - extra_state_attributes={ - "date": lambda wu: wu.data["current_observation"]["observation_time"] - }, - device_class=device_class, - ) - - -class WUDailyTextForecastSensorConfig(WUSensorConfig): - """Helper for defining sensor configurations for daily text forecasts.""" - - def __init__( - self, period: int, field: str, unit_of_measurement: str | None = None - ) -> None: - """Initialize daily text forecast sensor configuration. - - :param period: forecast period number - :param field: field name to use as value - :param unit_of_measurement: unit of measurement - """ - super().__init__( - friendly_name=lambda wu: wu.data["forecast"]["txt_forecast"]["forecastday"][ - period - ]["title"], - feature="forecast", - value=lambda wu: wu.data["forecast"]["txt_forecast"]["forecastday"][period][ - field - ], - entity_picture=lambda wu: wu.data["forecast"]["txt_forecast"][ - "forecastday" - ][period]["icon_url"], - unit_of_measurement=unit_of_measurement, - extra_state_attributes={ - "date": lambda wu: wu.data["forecast"]["txt_forecast"]["date"] - }, - ) - - -class WUDailySimpleForecastSensorConfig(WUSensorConfig): - """Helper for defining sensor configurations for daily simpleforecasts.""" - - def __init__( - self, - friendly_name: str, - period: int, - field: str, - wu_unit: str | None = None, - ha_unit: str | None = None, - icon=None, - device_class=None, - ) -> None: - """Initialize daily simple forecast sensor configuration. - - :param friendly_name: friendly_name of the sensor - :param period: forecast period number - :param field: field name to use as value - :param wu_unit: "fahrenheit", "celsius", "degrees" etc. see the example json at: - https://www.wunderground.com/weather/api/d/docs?d=data/forecast&MR=1 - :param ha_unit: corresponding unit in Home Assistant - """ - super().__init__( - friendly_name=friendly_name, - feature="forecast", - value=( - lambda wu: wu.data["forecast"]["simpleforecast"]["forecastday"][period][ - field - ][wu_unit] - ) - if wu_unit - else ( - lambda wu: wu.data["forecast"]["simpleforecast"]["forecastday"][period][ - field - ] - ), - unit_of_measurement=ha_unit, - entity_picture=lambda wu: wu.data["forecast"]["simpleforecast"][ - "forecastday" - ][period]["icon_url"] - if not icon - else None, - icon=icon, - extra_state_attributes={ - "date": lambda wu: wu.data["forecast"]["simpleforecast"]["forecastday"][ - period - ]["date"]["pretty"] - }, - device_class=device_class, - ) - - -class WUHourlyForecastSensorConfig(WUSensorConfig): - """Helper for defining sensor configurations for hourly text forecasts.""" - - def __init__(self, period: int, field: int) -> None: - """Initialize hourly forecast sensor configuration. - - :param period: forecast period number - :param field: field name to use as value - """ - super().__init__( - friendly_name=lambda wu: ( - f"{wu.data['hourly_forecast'][period]['FCTTIME']['weekday_name_abbrev']} " - f"{wu.data['hourly_forecast'][period]['FCTTIME']['civil']}" - ), - feature="hourly", - value=lambda wu: wu.data["hourly_forecast"][period][field], - entity_picture=lambda wu: wu.data["hourly_forecast"][period]["icon_url"], - extra_state_attributes={ - "temp_c": lambda wu: wu.data["hourly_forecast"][period]["temp"][ - "metric" - ], - "temp_f": lambda wu: wu.data["hourly_forecast"][period]["temp"][ - "english" - ], - "dewpoint_c": lambda wu: wu.data["hourly_forecast"][period]["dewpoint"][ - "metric" - ], - "dewpoint_f": lambda wu: wu.data["hourly_forecast"][period]["dewpoint"][ - "english" - ], - "precip_prop": lambda wu: wu.data["hourly_forecast"][period]["pop"], - "sky": lambda wu: wu.data["hourly_forecast"][period]["sky"], - "precip_mm": lambda wu: wu.data["hourly_forecast"][period]["qpf"][ - "metric" - ], - "precip_in": lambda wu: wu.data["hourly_forecast"][period]["qpf"][ - "english" - ], - "humidity": lambda wu: wu.data["hourly_forecast"][period]["humidity"], - "wind_kph": lambda wu: wu.data["hourly_forecast"][period]["wspd"][ - "metric" - ], - "wind_mph": lambda wu: wu.data["hourly_forecast"][period]["wspd"][ - "english" - ], - "pressure_mb": lambda wu: wu.data["hourly_forecast"][period]["mslp"][ - "metric" - ], - "pressure_inHg": lambda wu: wu.data["hourly_forecast"][period]["mslp"][ - "english" - ], - "date": lambda wu: wu.data["hourly_forecast"][period]["FCTTIME"][ - "pretty" - ], - }, - ) - - -class WUAlmanacSensorConfig(WUSensorConfig): - """Helper for defining field configurations for almanac sensors.""" - - def __init__( - self, - friendly_name: str | Callable, - field: str, - value_type: str, - wu_unit: str, - unit_of_measurement: str, - icon: str, - device_class=None, - ) -> None: - """Initialize almanac sensor configuration. - - :param friendly_name: Friendly name - :param field: value name returned in 'almanac' dict as returned by the WU API - :param value_type: "record" or "normal" - :param wu_unit: unit name in WU API - :param unit_of_measurement: unit of measurement - :param icon: icon name or URL - """ - super().__init__( - friendly_name=friendly_name, - feature="almanac", - value=lambda wu: wu.data["almanac"][field][value_type][wu_unit], - unit_of_measurement=unit_of_measurement, - icon=icon, - device_class="temperature", - ) - - -class WUAlertsSensorConfig(WUSensorConfig): - """Helper for defining field configuration for alerts.""" - - def __init__(self, friendly_name: str | Callable) -> None: - """Initialiize alerts sensor configuration. - - :param friendly_name: Friendly name - """ - super().__init__( - friendly_name=friendly_name, - feature="alerts", - value=lambda wu: len(wu.data["alerts"]), - icon=lambda wu: "mdi:alert-circle-outline" - if wu.data["alerts"] - else "mdi:check-circle-outline", - extra_state_attributes=self._get_attributes, - ) - - @staticmethod - def _get_attributes(rest): - - attrs = {} - - if "alerts" not in rest.data: - return attrs - - alerts = rest.data["alerts"] - multiple_alerts = len(alerts) > 1 - for data in alerts: - for alert in ALERTS_ATTRS: - if data[alert]: - if multiple_alerts: - dkey = f"{alert.capitalize()}_{data['type']}" - else: - dkey = alert.capitalize() - attrs[dkey] = data[alert] - return attrs - - -# Declaration of supported WU sensors -# (see above helper classes for argument explanation) - -SENSOR_TYPES = { - "alerts": WUAlertsSensorConfig("Alerts"), - "dewpoint_c": WUCurrentConditionsSensorConfig( - "Dewpoint", "dewpoint_c", "mdi:water", TEMP_CELSIUS - ), - "dewpoint_f": WUCurrentConditionsSensorConfig( - "Dewpoint", "dewpoint_f", "mdi:water", TEMP_FAHRENHEIT - ), - "dewpoint_string": WUCurrentConditionsSensorConfig( - "Dewpoint Summary", "dewpoint_string", "mdi:water" - ), - "feelslike_c": WUCurrentConditionsSensorConfig( - "Feels Like", "feelslike_c", "mdi:thermometer", TEMP_CELSIUS - ), - "feelslike_f": WUCurrentConditionsSensorConfig( - "Feels Like", "feelslike_f", "mdi:thermometer", TEMP_FAHRENHEIT - ), - "feelslike_string": WUCurrentConditionsSensorConfig( - "Feels Like", "feelslike_string", "mdi:thermometer" - ), - "heat_index_c": WUCurrentConditionsSensorConfig( - "Heat index", "heat_index_c", "mdi:thermometer", TEMP_CELSIUS - ), - "heat_index_f": WUCurrentConditionsSensorConfig( - "Heat index", "heat_index_f", "mdi:thermometer", TEMP_FAHRENHEIT - ), - "heat_index_string": WUCurrentConditionsSensorConfig( - "Heat Index Summary", "heat_index_string", "mdi:thermometer" - ), - "elevation": WUSensorConfig( - "Elevation", - "conditions", - value=lambda wu: wu.data["current_observation"]["observation_location"][ - "elevation" - ].split()[0], - unit_of_measurement=LENGTH_FEET, - icon="mdi:elevation-rise", - ), - "location": WUSensorConfig( - "Location", - "conditions", - value=lambda wu: wu.data["current_observation"]["display_location"]["full"], - icon="mdi:map-marker", - ), - "observation_time": WUCurrentConditionsSensorConfig( - "Observation Time", "observation_time", "mdi:clock" - ), - "precip_1hr_in": WUCurrentConditionsSensorConfig( - "Precipitation 1hr", "precip_1hr_in", "mdi:umbrella", LENGTH_INCHES - ), - "precip_1hr_metric": WUCurrentConditionsSensorConfig( - "Precipitation 1hr", "precip_1hr_metric", "mdi:umbrella", LENGTH_MILLIMETERS - ), - "precip_1hr_string": WUCurrentConditionsSensorConfig( - "Precipitation 1hr", "precip_1hr_string", "mdi:umbrella" - ), - "precip_today_in": WUCurrentConditionsSensorConfig( - "Precipitation Today", "precip_today_in", "mdi:umbrella", LENGTH_INCHES - ), - "precip_today_metric": WUCurrentConditionsSensorConfig( - "Precipitation Today", "precip_today_metric", "mdi:umbrella", LENGTH_MILLIMETERS - ), - "precip_today_string": WUCurrentConditionsSensorConfig( - "Precipitation Today", "precip_today_string", "mdi:umbrella" - ), - "pressure_in": WUCurrentConditionsSensorConfig( - "Pressure", "pressure_in", "mdi:gauge", PRESSURE_INHG, device_class="pressure" - ), - "pressure_mb": WUCurrentConditionsSensorConfig( - "Pressure", "pressure_mb", "mdi:gauge", "mb", device_class="pressure" - ), - "pressure_trend": WUCurrentConditionsSensorConfig( - "Pressure Trend", "pressure_trend", "mdi:gauge", device_class="pressure" - ), - "relative_humidity": WUSensorConfig( - "Relative Humidity", - "conditions", - value=lambda wu: int(wu.data["current_observation"]["relative_humidity"][:-1]), - unit_of_measurement=PERCENTAGE, - icon="mdi:water-percent", - device_class="humidity", - ), - "station_id": WUCurrentConditionsSensorConfig( - "Station ID", "station_id", "mdi:home" - ), - "solarradiation": WUCurrentConditionsSensorConfig( - "Solar Radiation", - "solarradiation", - "mdi:weather-sunny", - IRRADIATION_WATTS_PER_SQUARE_METER, - ), - "temperature_string": WUCurrentConditionsSensorConfig( - "Temperature Summary", "temperature_string", "mdi:thermometer" - ), - "temp_c": WUCurrentConditionsSensorConfig( - "Temperature", - "temp_c", - "mdi:thermometer", - TEMP_CELSIUS, - device_class="temperature", - ), - "temp_f": WUCurrentConditionsSensorConfig( - "Temperature", - "temp_f", - "mdi:thermometer", - TEMP_FAHRENHEIT, - device_class="temperature", - ), - "UV": WUCurrentConditionsSensorConfig("UV", "UV", "mdi:sunglasses"), - "visibility_km": WUCurrentConditionsSensorConfig( - "Visibility (km)", "visibility_km", "mdi:eye", LENGTH_KILOMETERS - ), - "visibility_mi": WUCurrentConditionsSensorConfig( - "Visibility (miles)", "visibility_mi", "mdi:eye", LENGTH_MILES - ), - "weather": WUCurrentConditionsSensorConfig("Weather Summary", "weather", None), - "wind_degrees": WUCurrentConditionsSensorConfig( - "Wind Degrees", "wind_degrees", "mdi:weather-windy", DEGREE - ), - "wind_dir": WUCurrentConditionsSensorConfig( - "Wind Direction", "wind_dir", "mdi:weather-windy" - ), - "wind_gust_kph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR - ), - "wind_gust_mph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR - ), - "wind_kph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR - ), - "wind_mph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR - ), - "wind_string": WUCurrentConditionsSensorConfig( - "Wind Summary", "wind_string", "mdi:weather-windy" - ), - "temp_high_record_c": WUAlmanacSensorConfig( - lambda wu: ( - f"High Temperature Record " - f"({wu.data['almanac']['temp_high']['recordyear']})" - ), - "temp_high", - "record", - "C", - TEMP_CELSIUS, - "mdi:thermometer", - ), - "temp_high_record_f": WUAlmanacSensorConfig( - lambda wu: ( - f"High Temperature Record " - f"({wu.data['almanac']['temp_high']['recordyear']})" - ), - "temp_high", - "record", - "F", - TEMP_FAHRENHEIT, - "mdi:thermometer", - ), - "temp_low_record_c": WUAlmanacSensorConfig( - lambda wu: ( - f"Low Temperature Record " - f"({wu.data['almanac']['temp_low']['recordyear']})" - ), - "temp_low", - "record", - "C", - TEMP_CELSIUS, - "mdi:thermometer", - ), - "temp_low_record_f": WUAlmanacSensorConfig( - lambda wu: ( - f"Low Temperature Record " - f"({wu.data['almanac']['temp_low']['recordyear']})" - ), - "temp_low", - "record", - "F", - TEMP_FAHRENHEIT, - "mdi:thermometer", - ), - "temp_low_avg_c": WUAlmanacSensorConfig( - "Historic Average of Low Temperatures for Today", - "temp_low", - "normal", - "C", - TEMP_CELSIUS, - "mdi:thermometer", - ), - "temp_low_avg_f": WUAlmanacSensorConfig( - "Historic Average of Low Temperatures for Today", - "temp_low", - "normal", - "F", - TEMP_FAHRENHEIT, - "mdi:thermometer", - ), - "temp_high_avg_c": WUAlmanacSensorConfig( - "Historic Average of High Temperatures for Today", - "temp_high", - "normal", - "C", - TEMP_CELSIUS, - "mdi:thermometer", - ), - "temp_high_avg_f": WUAlmanacSensorConfig( - "Historic Average of High Temperatures for Today", - "temp_high", - "normal", - "F", - TEMP_FAHRENHEIT, - "mdi:thermometer", - ), - "weather_1d": WUDailyTextForecastSensorConfig(0, "fcttext"), - "weather_1d_metric": WUDailyTextForecastSensorConfig(0, "fcttext_metric"), - "weather_1n": WUDailyTextForecastSensorConfig(1, "fcttext"), - "weather_1n_metric": WUDailyTextForecastSensorConfig(1, "fcttext_metric"), - "weather_2d": WUDailyTextForecastSensorConfig(2, "fcttext"), - "weather_2d_metric": WUDailyTextForecastSensorConfig(2, "fcttext_metric"), - "weather_2n": WUDailyTextForecastSensorConfig(3, "fcttext"), - "weather_2n_metric": WUDailyTextForecastSensorConfig(3, "fcttext_metric"), - "weather_3d": WUDailyTextForecastSensorConfig(4, "fcttext"), - "weather_3d_metric": WUDailyTextForecastSensorConfig(4, "fcttext_metric"), - "weather_3n": WUDailyTextForecastSensorConfig(5, "fcttext"), - "weather_3n_metric": WUDailyTextForecastSensorConfig(5, "fcttext_metric"), - "weather_4d": WUDailyTextForecastSensorConfig(6, "fcttext"), - "weather_4d_metric": WUDailyTextForecastSensorConfig(6, "fcttext_metric"), - "weather_4n": WUDailyTextForecastSensorConfig(7, "fcttext"), - "weather_4n_metric": WUDailyTextForecastSensorConfig(7, "fcttext_metric"), - "weather_1h": WUHourlyForecastSensorConfig(0, "condition"), - "weather_2h": WUHourlyForecastSensorConfig(1, "condition"), - "weather_3h": WUHourlyForecastSensorConfig(2, "condition"), - "weather_4h": WUHourlyForecastSensorConfig(3, "condition"), - "weather_5h": WUHourlyForecastSensorConfig(4, "condition"), - "weather_6h": WUHourlyForecastSensorConfig(5, "condition"), - "weather_7h": WUHourlyForecastSensorConfig(6, "condition"), - "weather_8h": WUHourlyForecastSensorConfig(7, "condition"), - "weather_9h": WUHourlyForecastSensorConfig(8, "condition"), - "weather_10h": WUHourlyForecastSensorConfig(9, "condition"), - "weather_11h": WUHourlyForecastSensorConfig(10, "condition"), - "weather_12h": WUHourlyForecastSensorConfig(11, "condition"), - "weather_13h": WUHourlyForecastSensorConfig(12, "condition"), - "weather_14h": WUHourlyForecastSensorConfig(13, "condition"), - "weather_15h": WUHourlyForecastSensorConfig(14, "condition"), - "weather_16h": WUHourlyForecastSensorConfig(15, "condition"), - "weather_17h": WUHourlyForecastSensorConfig(16, "condition"), - "weather_18h": WUHourlyForecastSensorConfig(17, "condition"), - "weather_19h": WUHourlyForecastSensorConfig(18, "condition"), - "weather_20h": WUHourlyForecastSensorConfig(19, "condition"), - "weather_21h": WUHourlyForecastSensorConfig(20, "condition"), - "weather_22h": WUHourlyForecastSensorConfig(21, "condition"), - "weather_23h": WUHourlyForecastSensorConfig(22, "condition"), - "weather_24h": WUHourlyForecastSensorConfig(23, "condition"), - "weather_25h": WUHourlyForecastSensorConfig(24, "condition"), - "weather_26h": WUHourlyForecastSensorConfig(25, "condition"), - "weather_27h": WUHourlyForecastSensorConfig(26, "condition"), - "weather_28h": WUHourlyForecastSensorConfig(27, "condition"), - "weather_29h": WUHourlyForecastSensorConfig(28, "condition"), - "weather_30h": WUHourlyForecastSensorConfig(29, "condition"), - "weather_31h": WUHourlyForecastSensorConfig(30, "condition"), - "weather_32h": WUHourlyForecastSensorConfig(31, "condition"), - "weather_33h": WUHourlyForecastSensorConfig(32, "condition"), - "weather_34h": WUHourlyForecastSensorConfig(33, "condition"), - "weather_35h": WUHourlyForecastSensorConfig(34, "condition"), - "weather_36h": WUHourlyForecastSensorConfig(35, "condition"), - "temp_high_1d_c": WUDailySimpleForecastSensorConfig( - "High Temperature Today", - 0, - "high", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_2d_c": WUDailySimpleForecastSensorConfig( - "High Temperature Tomorrow", - 1, - "high", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_3d_c": WUDailySimpleForecastSensorConfig( - "High Temperature in 3 Days", - 2, - "high", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_4d_c": WUDailySimpleForecastSensorConfig( - "High Temperature in 4 Days", - 3, - "high", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_1d_f": WUDailySimpleForecastSensorConfig( - "High Temperature Today", - 0, - "high", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_2d_f": WUDailySimpleForecastSensorConfig( - "High Temperature Tomorrow", - 1, - "high", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_3d_f": WUDailySimpleForecastSensorConfig( - "High Temperature in 3 Days", - 2, - "high", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_high_4d_f": WUDailySimpleForecastSensorConfig( - "High Temperature in 4 Days", - 3, - "high", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_1d_c": WUDailySimpleForecastSensorConfig( - "Low Temperature Today", - 0, - "low", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_2d_c": WUDailySimpleForecastSensorConfig( - "Low Temperature Tomorrow", - 1, - "low", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_3d_c": WUDailySimpleForecastSensorConfig( - "Low Temperature in 3 Days", - 2, - "low", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_4d_c": WUDailySimpleForecastSensorConfig( - "Low Temperature in 4 Days", - 3, - "low", - "celsius", - TEMP_CELSIUS, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_1d_f": WUDailySimpleForecastSensorConfig( - "Low Temperature Today", - 0, - "low", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_2d_f": WUDailySimpleForecastSensorConfig( - "Low Temperature Tomorrow", - 1, - "low", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_3d_f": WUDailySimpleForecastSensorConfig( - "Low Temperature in 3 Days", - 2, - "low", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "temp_low_4d_f": WUDailySimpleForecastSensorConfig( - "Low Temperature in 4 Days", - 3, - "low", - "fahrenheit", - TEMP_FAHRENHEIT, - "mdi:thermometer", - device_class="temperature", - ), - "wind_gust_1d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", - 0, - "maxwind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_2d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", - 1, - "maxwind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_3d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", - 2, - "maxwind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_4d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", - 3, - "maxwind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_1d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", - 0, - "maxwind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_2d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", - 1, - "maxwind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_3d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", - 2, - "maxwind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_gust_4d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", - 3, - "maxwind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_1d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", - 0, - "avewind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_2d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", - 1, - "avewind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_3d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", - 2, - "avewind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_4d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", - 3, - "avewind", - SPEED_KILOMETERS_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - "mdi:weather-windy", - ), - "wind_1d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", - 0, - "avewind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_2d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", - 1, - "avewind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_3d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", - 2, - "avewind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "wind_4d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", - 3, - "avewind", - SPEED_MILES_PER_HOUR, - SPEED_MILES_PER_HOUR, - "mdi:weather-windy", - ), - "precip_1d_mm": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity Today", - 0, - "qpf_allday", - LENGTH_MILLIMETERS, - LENGTH_MILLIMETERS, - "mdi:umbrella", - ), - "precip_2d_mm": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity Tomorrow", - 1, - "qpf_allday", - LENGTH_MILLIMETERS, - LENGTH_MILLIMETERS, - "mdi:umbrella", - ), - "precip_3d_mm": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity in 3 Days", - 2, - "qpf_allday", - LENGTH_MILLIMETERS, - LENGTH_MILLIMETERS, - "mdi:umbrella", - ), - "precip_4d_mm": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity in 4 Days", - 3, - "qpf_allday", - LENGTH_MILLIMETERS, - LENGTH_MILLIMETERS, - "mdi:umbrella", - ), - "precip_1d_in": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity Today", - 0, - "qpf_allday", - "in", - LENGTH_INCHES, - "mdi:umbrella", - ), - "precip_2d_in": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity Tomorrow", - 1, - "qpf_allday", - "in", - LENGTH_INCHES, - "mdi:umbrella", - ), - "precip_3d_in": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity in 3 Days", - 2, - "qpf_allday", - "in", - LENGTH_INCHES, - "mdi:umbrella", - ), - "precip_4d_in": WUDailySimpleForecastSensorConfig( - "Precipitation Intensity in 4 Days", - 3, - "qpf_allday", - "in", - LENGTH_INCHES, - "mdi:umbrella", - ), - "precip_1d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability Today", - 0, - "pop", - None, - PERCENTAGE, - "mdi:umbrella", - ), - "precip_2d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability Tomorrow", - 1, - "pop", - None, - PERCENTAGE, - "mdi:umbrella", - ), - "precip_3d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability in 3 Days", - 2, - "pop", - None, - PERCENTAGE, - "mdi:umbrella", - ), - "precip_4d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability in 4 Days", - 3, - "pop", - None, - PERCENTAGE, - "mdi:umbrella", - ), -} - -# Alert Attributes -ALERTS_ATTRS = ["date", "description", "expires", "message"] - -# Language Supported Codes -LANG_CODES = [ - "AF", - "AL", - "AR", - "HY", - "AZ", - "EU", - "BY", - "BU", - "LI", - "MY", - "CA", - "CN", - "TW", - "CR", - "CZ", - "DK", - "DV", - "NL", - "EN", - "EO", - "ET", - "FA", - "FI", - "FR", - "FC", - "GZ", - "DL", - "KA", - "GR", - "GU", - "HT", - "IL", - "HI", - "HU", - "IS", - "IO", - "ID", - "IR", - "IT", - "JP", - "JW", - "KM", - "KR", - "KU", - "LA", - "LV", - "LT", - "ND", - "MK", - "MT", - "GM", - "MI", - "MR", - "MN", - "NO", - "OC", - "PS", - "GN", - "PL", - "BR", - "PA", - "RO", - "RU", - "SR", - "SK", - "SL", - "SP", - "SI", - "SW", - "CH", - "TL", - "TT", - "TH", - "TR", - "TK", - "UA", - "UZ", - "VU", - "CY", - "SN", - "JI", - "YI", -] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PWS_ID): cv.string, - vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.All(vol.In(LANG_CODES)), - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - vol.Required(CONF_MONITORED_CONDITIONS): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None -): - """Set up the WUnderground sensor.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - pws_id = config.get(CONF_PWS_ID) - - rest = WUndergroundData( - hass, - config.get(CONF_API_KEY), - pws_id, - config.get(CONF_LANG), - latitude, - longitude, - ) - - if pws_id is None: - unique_id_base = f"@{longitude:06f},{latitude:06f}" - else: - # Manually specified weather station, use that for unique_id - unique_id_base = pws_id - sensors = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(WUndergroundSensor(hass, rest, variable, unique_id_base)) - - await rest.async_update() - if not rest.data: - raise PlatformNotReady - - async_add_entities(sensors, True) - - -class WUndergroundSensor(SensorEntity): - """Implementing the WUnderground sensor.""" - - def __init__( - self, hass: HomeAssistant, rest, condition, unique_id_base: str - ) -> None: - """Initialize the sensor.""" - self.rest = rest - self._condition = condition - self._state = None - self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} - self._icon = None - self._entity_picture = None - self._unit_of_measurement = self._cfg_expand("unit_of_measurement") - self.rest.request_feature(SENSOR_TYPES[condition].feature) - # This is only the suggested entity id, it might get changed by - # the entity registry later. - self.entity_id = sensor.ENTITY_ID_FORMAT.format(f"pws_{condition}") - self._unique_id = f"{unique_id_base},{condition}" - self._device_class = self._cfg_expand("device_class") - - def _cfg_expand(self, what, default=None): - """Parse and return sensor data.""" - cfg = SENSOR_TYPES[self._condition] - val = getattr(cfg, what) - if not callable(val): - return val - try: - val = val(self.rest) - except (KeyError, IndexError, TypeError, ValueError) as err: - _LOGGER.warning( - "Failed to expand cfg from WU API. Condition: %s Attr: %s Error: %s", - self._condition, - what, - repr(err), - ) - val = default - - return val - - def _update_attrs(self): - """Parse and update device state attributes.""" - attrs = self._cfg_expand("extra_state_attributes", {}) - - for (attr, callback) in attrs.items(): - if callable(callback): - try: - self._attributes[attr] = callback(self.rest) - except (KeyError, IndexError, TypeError, ValueError) as err: - _LOGGER.warning( - "Failed to update attrs from WU API." - " Condition: %s Attr: %s Error: %s", - self._condition, - attr, - repr(err), - ) - else: - self._attributes[attr] = callback - - @property - def name(self): - """Return the name of the sensor.""" - return self._cfg_expand("friendly_name") - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def icon(self): - """Return icon.""" - return self._icon - - @property - def entity_picture(self): - """Return the entity picture.""" - return self._entity_picture - - @property - def unit_of_measurement(self): - """Return the units of measurement.""" - return self._unit_of_measurement - - @property - def device_class(self): - """Return the units of measurement.""" - return self._device_class - - async def async_update(self): - """Update current conditions.""" - await self.rest.async_update() - - if not self.rest.data: - # no data, return - return - - self._state = self._cfg_expand("value") - self._update_attrs() - self._icon = self._cfg_expand("icon", super().icon) - url = self._cfg_expand("entity_picture") - if isinstance(url, str): - self._entity_picture = re.sub( - r"^http://", "https://", url, flags=re.IGNORECASE - ) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - -class WUndergroundData: - """Get data from WUnderground.""" - - def __init__(self, hass, api_key, pws_id, lang, latitude, longitude): - """Initialize the data object.""" - self._hass = hass - self._api_key = api_key - self._pws_id = pws_id - self._lang = f"lang:{lang}" - self._latitude = latitude - self._longitude = longitude - self._features = set() - self.data = None - self._session = async_get_clientsession(self._hass) - - def request_feature(self, feature): - """Register feature to be fetched from WU API.""" - self._features.add(feature) - - def _build_url(self, baseurl=_RESOURCE): - url = baseurl.format( - self._api_key, "/".join(sorted(self._features)), self._lang - ) - if self._pws_id: - url = f"{url}pws:{self._pws_id}" - else: - url = f"{url}{self._latitude},{self._longitude}" - - return f"{url}.json" - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from WUnderground.""" - try: - with async_timeout.timeout(10): - response = await self._session.get(self._build_url()) - result = await response.json() - if "error" in result["response"]: - raise ValueError(result["response"]["error"]["description"]) - self.data = result - except ValueError as err: - _LOGGER.error("Check WUnderground API %s", err.args) - except (asyncio.TimeoutError, aiohttp.ClientError) as err: - _LOGGER.error("Error fetching WUnderground data: %s", repr(err)) diff --git a/homeassistant/components/xbee/sensor.py b/homeassistant/components/xbee/sensor.py index 18e4b0c7aa1..b1d5ece7d57 100644 --- a/homeassistant/components/xbee/sensor.py +++ b/homeassistant/components/xbee/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure from homeassistant.components.sensor import SensorEntity -from homeassistant.const import CONF_TYPE, TEMP_CELSIUS +from homeassistant.const import CONF_TYPE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from . import DOMAIN, PLATFORM_SCHEMA, XBeeAnalogIn, XBeeAnalogInConfig, XBeeConfig @@ -46,6 +46,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class XBeeTemperatureSensor(SensorEntity): """Representation of XBee Pro temperature sensor.""" + _attr_device_class = DEVICE_CLASS_TEMPERATURE _attr_unit_of_measurement = TEMP_CELSIUS def __init__(self, config, device): diff --git a/homeassistant/components/xbox/translations/de.json b/homeassistant/components/xbox/translations/de.json index 04f32e05f8b..615c8f8cf2a 100644 --- a/homeassistant/components/xbox/translations/de.json +++ b/homeassistant/components/xbox/translations/de.json @@ -10,7 +10,7 @@ }, "step": { "pick_implementation": { - "title": "Authentifizierungsmethode w\u00e4hlen" + "title": "W\u00e4hle die Authentifizierungsmethode" } } } diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py index 494c9af920e..30f72a7ba59 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -58,7 +58,7 @@ class XiaomiGatewayLight(XiaomiDevice, LightEntity): self._state = False return True - rgbhexstr = "%x" % value + rgbhexstr = f"{value:x}" if len(rgbhexstr) > 8: _LOGGER.error( "Light RGB data error." diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py index 8b16b6491c7..c17cf080a60 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -194,7 +194,7 @@ class XiaomiGenericSwitch(XiaomiDevice, SwitchEntity): if not self._in_use: self._load_power = 0 - for key in [POWER_CONSUMED, ENERGY_CONSUMED]: + for key in (POWER_CONSUMED, ENERGY_CONSUMED): if key in data: self._power_consumed = round(float(data[key]), 2) break diff --git a/homeassistant/components/xiaomi_aqara/translations/de.json b/homeassistant/components/xiaomi_aqara/translations/de.json index 87120f09605..bc87f461c33 100644 --- a/homeassistant/components/xiaomi_aqara/translations/de.json +++ b/homeassistant/components/xiaomi_aqara/translations/de.json @@ -31,7 +31,7 @@ }, "user": { "data": { - "host": "IP-Adresse", + "host": "IP-Adresse (optional)", "interface": "Die zu verwendende Netzwerkschnittstelle", "mac": "MAC-Adresse" }, diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 076aed4d30c..36ee89ba7a0 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -2,12 +2,15 @@ from datetime import timedelta import logging +import async_timeout +from miio import AirHumidifier, AirHumidifierMiot, DeviceException from miio.gateway.gateway import GatewayException from homeassistant import config_entries, core from homeassistant.const import CONF_HOST, CONF_TOKEN -from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( ATTR_AVAILABLE, @@ -17,8 +20,11 @@ from .const import ( CONF_MODEL, DOMAIN, KEY_COORDINATOR, + KEY_DEVICE, MODELS_AIR_MONITOR, MODELS_FAN, + MODELS_HUMIDIFIER, + MODELS_HUMIDIFIER_MIOT, MODELS_LIGHT, MODELS_SWITCH, MODELS_VACUUM, @@ -30,6 +36,7 @@ _LOGGER = logging.getLogger(__name__) GATEWAY_PLATFORMS = ["alarm_control_panel", "light", "sensor", "switch"] SWITCH_PLATFORMS = ["switch"] FAN_PLATFORMS = ["fan"] +HUMIDIFIER_PLATFORMS = ["humidifier", "number", "select", "sensor", "switch"] LIGHT_PLATFORMS = ["light"] VACUUM_PLATFORMS = ["vacuum"] AIR_MONITOR_PLATFORMS = ["air_quality", "sensor"] @@ -51,6 +58,7 @@ async def async_setup_entry( ) +@callback def get_platforms(config_entry): """Return the platforms belonging to a config_entry.""" model = config_entry.data[CONF_MODEL] @@ -61,6 +69,8 @@ def get_platforms(config_entry): if flow_type == CONF_DEVICE: if model in MODELS_SWITCH: return SWITCH_PLATFORMS + if model in MODELS_HUMIDIFIER: + return HUMIDIFIER_PLATFORMS if model in MODELS_FAN: return FAN_PLATFORMS if model in MODELS_LIGHT: @@ -71,10 +81,71 @@ def get_platforms(config_entry): for air_monitor_model in MODELS_AIR_MONITOR: if model.startswith(air_monitor_model): return AIR_MONITOR_PLATFORMS - + _LOGGER.error( + "Unsupported device found! Please create an issue at " + "https://github.com/syssi/xiaomi_airpurifier/issues " + "and provide the following data: %s", + model, + ) return [] +async def async_create_miio_device_and_coordinator( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up a data coordinator and one miio device to service multiple entities.""" + model = entry.data[CONF_MODEL] + host = entry.data[CONF_HOST] + token = entry.data[CONF_TOKEN] + name = entry.title + device = None + + if model not in MODELS_HUMIDIFIER: + return + + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + + if model in MODELS_HUMIDIFIER_MIOT: + device = AirHumidifierMiot(host, token) + else: + device = AirHumidifier(host, token, model=model) + + # Removing fan platform entity for humidifiers and migrate the name to the config entry for migration + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id("fan", DOMAIN, entry.unique_id) + if entity_id: + # This check is entities that have a platform migration only and should be removed in the future + if migrate_entity_name := entity_registry.async_get(entity_id).name: + hass.config_entries.async_update_entry(entry, title=migrate_entity_name) + entity_registry.async_remove(entity_id) + + async def async_update_data(): + """Fetch data from the device using async_add_executor_job.""" + try: + async with async_timeout.timeout(10): + return await hass.async_add_executor_job(device.status) + + except DeviceException as ex: + raise UpdateFailed(ex) from ex + + # Create update miio device and coordinator + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=name, + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=60), + ) + hass.data[DOMAIN][entry.entry_id] = { + KEY_DEVICE: device, + KEY_COORDINATOR: coordinator, + } + + # Trigger first data fetch + await coordinator.async_config_entry_first_refresh() + + async def async_setup_gateway_entry( hass: core.HomeAssistant, entry: config_entries.ConfigEntry ): @@ -130,7 +201,6 @@ async def async_setup_gateway_entry( coordinator = DataUpdateCoordinator( hass, _LOGGER, - # Name of the data. For logging purposes. name=name, update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. @@ -155,6 +225,7 @@ async def async_setup_device_entry( ): """Set up the Xiaomi Miio device component from a config entry.""" platforms = get_platforms(entry) + await async_create_miio_device_and_coordinator(hass, entry) if not platforms: return False diff --git a/homeassistant/components/xiaomi_miio/const.py b/homeassistant/components/xiaomi_miio/const.py index 27d0a34bf39..a2f7679bf1b 100644 --- a/homeassistant/components/xiaomi_miio/const.py +++ b/homeassistant/components/xiaomi_miio/const.py @@ -15,10 +15,16 @@ CONF_MANUAL = "manual" # Options flow CONF_CLOUD_SUBDEVICES = "cloud_subdevices" +# Keys KEY_COORDINATOR = "coordinator" +KEY_DEVICE = "device" +# Attributes ATTR_AVAILABLE = "available" +# Status +SUCCESS = ["ok"] + # Cloud SERVER_COUNTRY_CODES = ["cn", "de", "i2", "ru", "sg", "us"] DEFAULT_CLOUD_COUNTRY = "cn" @@ -70,10 +76,12 @@ MODELS_FAN_MIIO = [ MODEL_AIRPURIFIER_SA2, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_2H, + MODEL_AIRFRESH_VA2, +] +MODELS_HUMIDIFIER_MIIO = [ MODEL_AIRHUMIDIFIER_V1, MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1, - MODEL_AIRFRESH_VA2, ] # AirQuality Models @@ -108,7 +116,8 @@ MODELS_SWITCH = [ "chuangmi.plug.hmi205", "chuangmi.plug.hmi206", ] -MODELS_FAN = MODELS_FAN_MIIO + MODELS_HUMIDIFIER_MIOT + MODELS_PURIFIER_MIOT +MODELS_FAN = MODELS_FAN_MIIO + MODELS_PURIFIER_MIOT +MODELS_HUMIDIFIER = MODELS_HUMIDIFIER_MIOT + MODELS_HUMIDIFIER_MIIO MODELS_LIGHT = ( MODELS_LIGHT_EYECARE + MODELS_LIGHT_CEILING @@ -125,17 +134,27 @@ MODELS_AIR_MONITOR = [ ] MODELS_ALL_DEVICES = ( - MODELS_SWITCH + MODELS_VACUUM + MODELS_AIR_MONITOR + MODELS_FAN + MODELS_LIGHT + MODELS_SWITCH + + MODELS_VACUUM + + MODELS_AIR_MONITOR + + MODELS_FAN + + MODELS_HUMIDIFIER + + MODELS_LIGHT ) MODELS_ALL = MODELS_ALL_DEVICES + MODELS_GATEWAY -# Fan Services +# Fan/Humidifier Services SERVICE_SET_BUZZER_ON = "fan_set_buzzer_on" SERVICE_SET_BUZZER_OFF = "fan_set_buzzer_off" +SERVICE_SET_BUZZER = "set_buzzer" +SERVICE_SET_CLEAN = "set_clean" SERVICE_SET_FAN_LED_ON = "fan_set_led_on" SERVICE_SET_FAN_LED_OFF = "fan_set_led_off" +SERVICE_SET_FAN_LED = "fan_set_led" +SERVICE_SET_LED_BRIGHTNESS = "set_led_brightness" SERVICE_SET_CHILD_LOCK_ON = "fan_set_child_lock_on" SERVICE_SET_CHILD_LOCK_OFF = "fan_set_child_lock_off" +SERVICE_SET_CHILD_LOCK = "set_child_lock" SERVICE_SET_LED_BRIGHTNESS = "fan_set_led_brightness" SERVICE_SET_FAVORITE_LEVEL = "fan_set_favorite_level" SERVICE_SET_FAN_LEVEL = "fan_set_fan_level" @@ -146,9 +165,7 @@ SERVICE_SET_LEARN_MODE_OFF = "fan_set_learn_mode_off" SERVICE_SET_VOLUME = "fan_set_volume" SERVICE_RESET_FILTER = "fan_reset_filter" SERVICE_SET_EXTRA_FEATURES = "fan_set_extra_features" -SERVICE_SET_TARGET_HUMIDITY = "fan_set_target_humidity" -SERVICE_SET_DRY_ON = "fan_set_dry_on" -SERVICE_SET_DRY_OFF = "fan_set_dry_off" +SERVICE_SET_DRY = "set_dry" SERVICE_SET_MOTOR_SPEED = "fan_set_motor_speed" # Light Services @@ -180,3 +197,94 @@ SERVICE_STOP_REMOTE_CONTROL = "vacuum_remote_control_stop" SERVICE_CLEAN_SEGMENT = "vacuum_clean_segment" SERVICE_CLEAN_ZONE = "vacuum_clean_zone" SERVICE_GOTO = "vacuum_goto" + +# Features +FEATURE_SET_BUZZER = 1 +FEATURE_SET_LED = 2 +FEATURE_SET_CHILD_LOCK = 4 +FEATURE_SET_LED_BRIGHTNESS = 8 +FEATURE_SET_FAVORITE_LEVEL = 16 +FEATURE_SET_AUTO_DETECT = 32 +FEATURE_SET_LEARN_MODE = 64 +FEATURE_SET_VOLUME = 128 +FEATURE_RESET_FILTER = 256 +FEATURE_SET_EXTRA_FEATURES = 512 +FEATURE_SET_TARGET_HUMIDITY = 1024 +FEATURE_SET_DRY = 2048 +FEATURE_SET_FAN_LEVEL = 4096 +FEATURE_SET_MOTOR_SPEED = 8192 +FEATURE_SET_CLEAN = 16384 + +FEATURE_FLAGS_AIRPURIFIER = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_LEARN_MODE + | FEATURE_RESET_FILTER + | FEATURE_SET_EXTRA_FEATURES +) + +FEATURE_FLAGS_AIRPURIFIER_PRO = ( + FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_AUTO_DETECT + | FEATURE_SET_VOLUME +) + +FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = ( + FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_VOLUME +) + +FEATURE_FLAGS_AIRPURIFIER_2S = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL +) + +FEATURE_FLAGS_AIRPURIFIER_3 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_FAVORITE_LEVEL + | FEATURE_SET_FAN_LEVEL + | FEATURE_SET_LED_BRIGHTNESS +) + +FEATURE_FLAGS_AIRPURIFIER_V3 = ( + FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED +) + +FEATURE_FLAGS_AIRHUMIDIFIER = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_TARGET_HUMIDITY +) + +FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY + +FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_SET_TARGET_HUMIDITY + | FEATURE_SET_DRY + | FEATURE_SET_MOTOR_SPEED + | FEATURE_SET_CLEAN +) + +FEATURE_FLAGS_AIRFRESH = ( + FEATURE_SET_BUZZER + | FEATURE_SET_CHILD_LOCK + | FEATURE_SET_LED + | FEATURE_SET_LED_BRIGHTNESS + | FEATURE_RESET_FILTER + | FEATURE_SET_EXTRA_FEATURES +) diff --git a/homeassistant/components/xiaomi_miio/device.py b/homeassistant/components/xiaomi_miio/device.py index 081b910efdb..f8402138f21 100644 --- a/homeassistant/components/xiaomi_miio/device.py +++ b/homeassistant/components/xiaomi_miio/device.py @@ -1,4 +1,5 @@ """Code to handle a Xiaomi Device.""" +from functools import partial import logging from construct.core import ChecksumError @@ -7,6 +8,7 @@ from miio import Device, DeviceException from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_MAC, CONF_MODEL, DOMAIN @@ -73,6 +75,7 @@ class XiaomiMiioEntity(Entity): self._device_id = entry.unique_id self._unique_id = unique_id self._name = name + self._available = None @property def unique_id(self): @@ -98,3 +101,59 @@ class XiaomiMiioEntity(Entity): device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} return device_info + + +class XiaomiCoordinatedMiioEntity(CoordinatorEntity): + """Representation of a base a coordinated Xiaomi Miio Entity.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the coordinated Xiaomi Miio Device.""" + super().__init__(coordinator) + self._device = device + self._model = entry.data[CONF_MODEL] + self._mac = entry.data[CONF_MAC] + self._device_id = entry.unique_id + self._device_name = entry.title + self._unique_id = unique_id + self._name = name + + @property + def unique_id(self): + """Return an unique ID.""" + return self._unique_id + + @property + def name(self): + """Return the name of this entity, if any.""" + return self._name + + @property + def device_info(self): + """Return the device info.""" + device_info = { + "identifiers": {(DOMAIN, self._device_id)}, + "manufacturer": "Xiaomi", + "name": self._device_name, + "model": self._model, + } + + if self._mac is not None: + device_info["connections"] = {(dr.CONNECTION_NETWORK_MAC, self._mac)} + + return device_info + + async def _try_command(self, mask_error, func, *args, **kwargs): + """Call a miio device command handling error messages.""" + try: + result = await self.hass.async_add_executor_job( + partial(func, *args, **kwargs) + ) + + _LOGGER.debug("Response received from miio device: %s", result) + + return True + except DeviceException as exc: + if self.available: + _LOGGER.error(mask_error, exc) + + return False diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index a5a5122ea07..c58d9ad0c66 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -5,27 +5,11 @@ from functools import partial import logging import math -from miio import ( - AirFresh, - AirHumidifier, - AirHumidifierMiot, - AirPurifier, - AirPurifierMiot, - DeviceException, -) +from miio import AirFresh, AirPurifier, AirPurifierMiot, DeviceException from miio.airfresh import ( LedBrightness as AirfreshLedBrightness, OperationMode as AirfreshOperationMode, ) -from miio.airhumidifier import ( - LedBrightness as AirhumidifierLedBrightness, - OperationMode as AirhumidifierOperationMode, -) -from miio.airhumidifier_miot import ( - LedBrightness as AirhumidifierMiotLedBrightness, - OperationMode as AirhumidifierMiotOperationMode, - PressedButton as AirhumidifierPressedButton, -) from miio.airpurifier import ( LedBrightness as AirpurifierLedBrightness, OperationMode as AirpurifierOperationMode, @@ -38,9 +22,6 @@ import voluptuous as vol from homeassistant.components.fan import ( PLATFORM_SCHEMA, - SPEED_HIGH, - SPEED_LOW, - SPEED_MEDIUM, SUPPORT_PRESET_MODE, SUPPORT_SET_SPEED, FanEntity, @@ -64,16 +45,23 @@ from .const import ( CONF_DEVICE, CONF_FLOW_TYPE, DOMAIN, - MODEL_AIRHUMIDIFIER_CA1, - MODEL_AIRHUMIDIFIER_CA4, - MODEL_AIRHUMIDIFIER_CB1, + FEATURE_RESET_FILTER, + FEATURE_SET_AUTO_DETECT, + FEATURE_SET_BUZZER, + FEATURE_SET_CHILD_LOCK, + FEATURE_SET_EXTRA_FEATURES, + FEATURE_SET_FAN_LEVEL, + FEATURE_SET_FAVORITE_LEVEL, + FEATURE_SET_LEARN_MODE, + FEATURE_SET_LED, + FEATURE_SET_LED_BRIGHTNESS, + FEATURE_SET_VOLUME, MODEL_AIRPURIFIER_2H, MODEL_AIRPURIFIER_2S, MODEL_AIRPURIFIER_PRO, MODEL_AIRPURIFIER_PRO_V7, MODEL_AIRPURIFIER_V3, MODELS_FAN, - MODELS_HUMIDIFIER_MIOT, MODELS_PURIFIER_MIOT, SERVICE_RESET_FILTER, SERVICE_SET_AUTO_DETECT_OFF, @@ -82,8 +70,6 @@ from .const import ( SERVICE_SET_BUZZER_ON, SERVICE_SET_CHILD_LOCK_OFF, SERVICE_SET_CHILD_LOCK_ON, - SERVICE_SET_DRY_OFF, - SERVICE_SET_DRY_ON, SERVICE_SET_EXTRA_FEATURES, SERVICE_SET_FAN_LED_OFF, SERVICE_SET_FAN_LED_ON, @@ -92,9 +78,8 @@ from .const import ( SERVICE_SET_LEARN_MODE_OFF, SERVICE_SET_LEARN_MODE_ON, SERVICE_SET_LED_BRIGHTNESS, - SERVICE_SET_MOTOR_SPEED, - SERVICE_SET_TARGET_HUMIDITY, SERVICE_SET_VOLUME, + SUCCESS, ) from .device import XiaomiMiioEntity @@ -150,20 +135,6 @@ ATTR_VOLUME = "volume" ATTR_USE_TIME = "use_time" ATTR_BUTTON_PRESSED = "button_pressed" -# Air Humidifier -ATTR_TARGET_HUMIDITY = "target_humidity" -ATTR_TRANS_LEVEL = "trans_level" -ATTR_HARDWARE_VERSION = "hardware_version" - -# Air Humidifier CA -# ATTR_MOTOR_SPEED = "motor_speed" -ATTR_DEPTH = "depth" -ATTR_DRY = "dry" - -# Air Humidifier CA4 -ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" -ATTR_FAHRENHEIT = "fahrenheit" - # Air Fresh ATTR_CO2 = "co2" @@ -283,41 +254,6 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { ATTR_BUTTON_PRESSED: "button_pressed", } -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON = { - ATTR_TEMPERATURE: "temperature", - ATTR_HUMIDITY: "humidity", - ATTR_MODE: "mode", - ATTR_BUZZER: "buzzer", - ATTR_CHILD_LOCK: "child_lock", - ATTR_TARGET_HUMIDITY: "target_humidity", - ATTR_LED_BRIGHTNESS: "led_brightness", - ATTR_USE_TIME: "use_time", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_TRANS_LEVEL: "trans_level", - ATTR_BUTTON_PRESSED: "button_pressed", - ATTR_HARDWARE_VERSION: "hardware_version", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA_AND_CB = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_MOTOR_SPEED: "motor_speed", - ATTR_DEPTH: "depth", - ATTR_DRY: "dry", - ATTR_HARDWARE_VERSION: "hardware_version", -} - -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA4 = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, - ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", - ATTR_BUTTON_PRESSED: "button_pressed", - ATTR_DRY: "dry", - ATTR_FAHRENHEIT: "fahrenheit", - ATTR_MOTOR_SPEED: "motor_speed", -} - AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_TEMPERATURE: "temperature", ATTR_AIR_QUALITY_INDEX: "aqi", @@ -336,7 +272,6 @@ AVAILABLE_ATTRIBUTES_AIRFRESH = { ATTR_EXTRA_FEATURES: "extra_features", } -OPERATION_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] PRESET_MODES_AIRPURIFIER = ["Auto", "Silent", "Favorite", "Idle"] OPERATION_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] PRESET_MODES_AIRPURIFIER_PRO = ["Auto", "Silent", "Favorite"] @@ -366,25 +301,6 @@ PRESET_MODES_AIRPURIFIER_V3 = [ ] OPERATION_MODES_AIRFRESH = ["Auto", "Silent", "Interval", "Low", "Middle", "Strong"] PRESET_MODES_AIRFRESH = ["Auto", "Interval"] -PRESET_MODES_AIRHUMIDIFIER = ["Auto"] -PRESET_MODES_AIRHUMIDIFIER_CA4 = ["Auto"] - -SUCCESS = ["ok"] - -FEATURE_SET_BUZZER = 1 -FEATURE_SET_LED = 2 -FEATURE_SET_CHILD_LOCK = 4 -FEATURE_SET_LED_BRIGHTNESS = 8 -FEATURE_SET_FAVORITE_LEVEL = 16 -FEATURE_SET_AUTO_DETECT = 32 -FEATURE_SET_LEARN_MODE = 64 -FEATURE_SET_VOLUME = 128 -FEATURE_RESET_FILTER = 256 -FEATURE_SET_EXTRA_FEATURES = 512 -FEATURE_SET_TARGET_HUMIDITY = 1024 -FEATURE_SET_DRY = 2048 -FEATURE_SET_FAN_LEVEL = 4096 -FEATURE_SET_MOTOR_SPEED = 8192 FEATURE_FLAGS_AIRPURIFIER = ( FEATURE_SET_BUZZER @@ -432,25 +348,6 @@ FEATURE_FLAGS_AIRPURIFIER_V3 = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK | FEATURE_SET_LED ) -FEATURE_FLAGS_AIRHUMIDIFIER = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY -) - -FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB = FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY - -FEATURE_FLAGS_AIRHUMIDIFIER_CA4 = ( - FEATURE_SET_BUZZER - | FEATURE_SET_CHILD_LOCK - | FEATURE_SET_LED_BRIGHTNESS - | FEATURE_SET_TARGET_HUMIDITY - | FEATURE_SET_DRY - | FEATURE_SET_MOTOR_SPEED -) - FEATURE_FLAGS_AIRFRESH = ( FEATURE_SET_BUZZER | FEATURE_SET_CHILD_LOCK @@ -482,22 +379,6 @@ SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend( {vol.Required(ATTR_FEATURES): cv.positive_int} ) -SERVICE_SCHEMA_TARGET_HUMIDITY = AIRPURIFIER_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_HUMIDITY): vol.All( - vol.Coerce(int), vol.In([30, 40, 50, 60, 70, 80]) - ) - } -) - -SERVICE_SCHEMA_MOTOR_SPEED = AIRPURIFIER_SERVICE_SCHEMA.extend( - { - vol.Required(ATTR_MOTOR_SPEED): vol.All( - vol.Coerce(int), vol.Clamp(min=200, max=2000) - ) - } -) - SERVICE_TO_METHOD = { SERVICE_SET_BUZZER_ON: {"method": "async_set_buzzer_on"}, SERVICE_SET_BUZZER_OFF: {"method": "async_set_buzzer_off"}, @@ -527,16 +408,6 @@ SERVICE_TO_METHOD = { "method": "async_set_extra_features", "schema": SERVICE_SCHEMA_EXTRA_FEATURES, }, - SERVICE_SET_TARGET_HUMIDITY: { - "method": "async_set_target_humidity", - "schema": SERVICE_SCHEMA_TARGET_HUMIDITY, - }, - SERVICE_SET_DRY_ON: {"method": "async_set_dry_on"}, - SERVICE_SET_DRY_OFF: {"method": "async_set_dry_off"}, - SERVICE_SET_MOTOR_SPEED: { - "method": "async_set_motor_speed", - "schema": SERVICE_SCHEMA_MOTOR_SPEED, - }, } @@ -579,14 +450,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): elif model.startswith("zhimi.airpurifier."): air_purifier = AirPurifier(host, token) entity = XiaomiAirPurifier(name, air_purifier, config_entry, unique_id) - elif model in MODELS_HUMIDIFIER_MIOT: - air_humidifier = AirHumidifierMiot(host, token) - entity = XiaomiAirHumidifierMiot( - name, air_humidifier, config_entry, unique_id - ) - elif model.startswith("zhimi.humidifier."): - air_humidifier = AirHumidifier(host, token, model=model) - entity = XiaomiAirHumidifier(name, air_humidifier, config_entry, unique_id) elif model.startswith("zhimi.airfresh."): air_fresh = AirFresh(host, token) entity = XiaomiAirFresh(name, air_fresh, config_entry, unique_id) @@ -634,10 +497,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if update_tasks: await asyncio.wait(update_tasks) - for air_purifier_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[air_purifier_service].get( - "schema", AIRPURIFIER_SERVICE_SCHEMA - ) + for air_purifier_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", AIRPURIFIER_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, air_purifier_service, async_service_handler, schema=schema ) @@ -905,10 +766,10 @@ class XiaomiAirPurifier(XiaomiGenericDevice): self._device_features = FEATURE_FLAGS_AIRPURIFIER self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER self._preset_modes = PRESET_MODES_AIRPURIFIER - self._supported_features = SUPPORT_SET_SPEED | SUPPORT_PRESET_MODE - self._speed_count = 4 + self._supported_features = SUPPORT_PRESET_MODE + self._speed_count = 1 # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = OPERATION_MODES_AIRPURIFIER + self._speed_list = [] self._state_attrs.update( {attribute: None for attribute in self._available_attributes} @@ -1250,345 +1111,6 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): ) -class XiaomiAirHumidifier(XiaomiGenericDevice): - """Representation of a Xiaomi Air Humidifier.""" - - SPEED_MODE_MAPPING = { - 1: AirhumidifierOperationMode.Silent, - 2: AirhumidifierOperationMode.Medium, - 3: AirhumidifierOperationMode.High, - 4: AirhumidifierOperationMode.Strong, - } - - REVERSE_SPEED_MODE_MAPPING = {v: k for k, v in SPEED_MODE_MAPPING.items()} - - PRESET_MODE_MAPPING = { - "Auto": AirhumidifierOperationMode.Auto, - } - - def __init__(self, name, device, entry, unique_id): - """Initialize the plug switch.""" - super().__init__(name, device, entry, unique_id) - self._percentage = None - self._preset_mode = None - self._supported_features = SUPPORT_SET_SPEED - self._preset_modes = [] - if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA_AND_CB - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Strong - ] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 3 - elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA4 - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 3 - else: - self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER - self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER - # the speed_list attribute is deprecated, support will end with release 2021.7 - self._speed_list = [ - mode.name - for mode in AirhumidifierOperationMode - if mode is not AirhumidifierOperationMode.Auto - ] - self._supported_features |= SUPPORT_PRESET_MODE - self._preset_modes = PRESET_MODES_AIRHUMIDIFIER - self._speed_count = 4 - - self._state_attrs.update( - {attribute: None for attribute in self._available_attributes} - ) - - async def async_update(self): - """Fetch state from the device.""" - # On state change the device doesn't provide the new state immediately. - if self._skip_update: - self._skip_update = False - return - - try: - state = await self.hass.async_add_executor_job(self._device.status) - _LOGGER.debug("Got new state: %s", state) - - self._available = True - self._state = state.is_on - self._state_attrs.update( - { - key: self._extract_value_from_attribute(state, value) - for key, value in self._available_attributes.items() - } - ) - - except DeviceException as ex: - if self._available: - self._available = False - _LOGGER.error("Got exception while fetching the state: %s", ex) - - @property - def preset_mode(self): - """Get the active preset mode.""" - if self._state: - preset_mode = AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]).name - return preset_mode if preset_mode in self._preset_modes else None - - return None - - @property - def percentage(self): - """Return the current percentage based speed.""" - if self._state: - mode = AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]) - if mode in self.REVERSE_SPEED_MODE_MAPPING: - return ranged_value_to_percentage( - (1, self._speed_count), self.REVERSE_SPEED_MODE_MAPPING[mode] - ) - - return None - - # the speed attribute is deprecated, support will end with release 2021.7 - @property - def speed(self): - """Return the current speed.""" - if self._state: - return AirhumidifierOperationMode(self._state_attrs[ATTR_MODE]).name - - return None - - async def async_set_percentage(self, percentage: int) -> None: - """Set the percentage of the fan. - - This method is a coroutine. - """ - speed_mode = math.ceil( - percentage_to_ranged_value((1, self._speed_count), percentage) - ) - if speed_mode: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierOperationMode(self.SPEED_MODE_MAPPING[speed_mode]), - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - - This method is a coroutine. - """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.PRESET_MODE_MAPPING[preset_mode], - ) - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Set the speed of the fan.""" - if self.supported_features & SUPPORT_SET_SPEED == 0: - return - - _LOGGER.debug("Setting the operation mode to: %s", speed) - - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierOperationMode[speed.title()], - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirhumidifierLedBrightness(brightness), - ) - - async def async_set_target_humidity(self, humidity: int = 40): - """Set the target humidity.""" - if self._device_features & FEATURE_SET_TARGET_HUMIDITY == 0: - return - - await self._try_command( - "Setting the target humidity of the miio device failed.", - self._device.set_target_humidity, - humidity, - ) - - async def async_set_dry_on(self): - """Turn the dry mode on.""" - if self._device_features & FEATURE_SET_DRY == 0: - return - - await self._try_command( - "Turning the dry mode of the miio device off failed.", - self._device.set_dry, - True, - ) - - async def async_set_dry_off(self): - """Turn the dry mode off.""" - if self._device_features & FEATURE_SET_DRY == 0: - return - - await self._try_command( - "Turning the dry mode of the miio device off failed.", - self._device.set_dry, - False, - ) - - -class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): - """Representation of a Xiaomi Air Humidifier (MiOT protocol).""" - - PRESET_MODE_MAPPING = { - AirhumidifierMiotOperationMode.Auto: "Auto", - } - - REVERSE_PRESET_MODE_MAPPING = {v: k for k, v in PRESET_MODE_MAPPING.items()} - - SPEED_MAPPING = { - AirhumidifierMiotOperationMode.Low: SPEED_LOW, - AirhumidifierMiotOperationMode.Mid: SPEED_MEDIUM, - AirhumidifierMiotOperationMode.High: SPEED_HIGH, - } - - REVERSE_SPEED_MAPPING = {v: k for k, v in SPEED_MAPPING.items()} - - SPEEDS = [ - AirhumidifierMiotOperationMode.Low, - AirhumidifierMiotOperationMode.Mid, - AirhumidifierMiotOperationMode.High, - ] - - # the speed attribute is deprecated, support will end with release 2021.7 - # it is added here for compatibility - @property - def speed(self): - """Return current legacy speed.""" - if ( - self.state - and AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - in self.SPEED_MAPPING - ): - return self.SPEED_MAPPING[ - AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - ] - return None - - @property - def percentage(self): - """Return the current percentage based speed.""" - if ( - self.state - and AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - in self.SPEEDS - ): - return ranged_value_to_percentage( - (1, self.speed_count), self._state_attrs[ATTR_MODE] - ) - - return None - - @property - def preset_mode(self): - """Return the current preset_mode.""" - if self._state: - mode = self.PRESET_MODE_MAPPING.get( - AirhumidifierMiotOperationMode(self._state_attrs[ATTR_MODE]) - ) - if mode in self._preset_modes: - return mode - - return None - - @property - def button_pressed(self): - """Return the last button pressed.""" - if self._state: - return AirhumidifierPressedButton( - self._state_attrs[ATTR_BUTTON_PRESSED] - ).name - - return None - - # the async_set_speed function is deprecated, support will end with release 2021.7 - # it is added here only for compatibility with legacy speeds - async def async_set_speed(self, speed: str) -> None: - """Override for set async_set_speed of the super() class.""" - if speed and speed in self.REVERSE_SPEED_MAPPING: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.REVERSE_SPEED_MAPPING[speed], - ) - - async def async_set_percentage(self, percentage: int) -> None: - """Set the percentage of the fan. - - This method is a coroutine. - """ - mode = math.ceil(percentage_to_ranged_value((1, 3), percentage)) - if mode: - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - AirhumidifierMiotOperationMode(mode), - ) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode of the fan. - - This method is a coroutine. - """ - if preset_mode not in self.preset_modes: - _LOGGER.warning("'%s'is not a valid preset mode", preset_mode) - return - await self._try_command( - "Setting operation mode of the miio device failed.", - self._device.set_mode, - self.REVERSE_PRESET_MODE_MAPPING[preset_mode], - ) - - async def async_set_led_brightness(self, brightness: int = 2): - """Set the led brightness.""" - if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: - return - - await self._try_command( - "Setting the led brightness of the miio device failed.", - self._device.set_led_brightness, - AirhumidifierMiotLedBrightness(brightness), - ) - - async def async_set_motor_speed(self, motor_speed: int = 400): - """Set the target motor speed.""" - if self._device_features & FEATURE_SET_MOTOR_SPEED == 0: - return - - await self._try_command( - "Setting the target motor speed of the miio device failed.", - self._device.set_speed, - motor_speed, - ) - - class XiaomiAirFresh(XiaomiGenericDevice): """Representation of a Xiaomi Air Fresh.""" diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py new file mode 100644 index 00000000000..aee2c237066 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/humidifier.py @@ -0,0 +1,366 @@ +"""Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier with humidifier entity.""" +from enum import Enum +import logging +import math + +from miio.airhumidifier import OperationMode as AirhumidifierOperationMode +from miio.airhumidifier_miot import OperationMode as AirhumidifierMiotOperationMode + +from homeassistant.components.humidifier import HumidifierEntity +from homeassistant.components.humidifier.const import ( + DEFAULT_MAX_HUMIDITY, + DEFAULT_MIN_HUMIDITY, + DEVICE_CLASS_HUMIDIFIER, + SUPPORT_MODES, +) +from homeassistant.const import ATTR_MODE +from homeassistant.core import callback +from homeassistant.util.percentage import percentage_to_ranged_value + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER_MIOT, +) +from .device import XiaomiCoordinatedMiioEntity + +_LOGGER = logging.getLogger(__name__) + +# Air Humidifier +ATTR_TARGET_HUMIDITY = "target_humidity" + +AVAILABLE_ATTRIBUTES = { + ATTR_MODE: "mode", + ATTR_TARGET_HUMIDITY: "target_humidity", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Humidifier from a config entry.""" + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + + entities = [] + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + name = config_entry.title + + if model in MODELS_HUMIDIFIER_MIOT: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifierMiot( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) + else: + air_humidifier = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + entity = XiaomiAirHumidifier( + name, + air_humidifier, + config_entry, + unique_id, + coordinator, + ) + + entities.append(entity) + + async_add_entities(entities) + + +class XiaomiGenericHumidifier(XiaomiCoordinatedMiioEntity, HumidifierEntity): + """Representation of a generic Xiaomi humidifier device.""" + + _attr_device_class = DEVICE_CLASS_HUMIDIFIER + _attr_supported_features = SUPPORT_MODES + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the generic Xiaomi device.""" + super().__init__(name, device, entry, unique_id, coordinator=coordinator) + + self._state = None + self._attributes = {} + self._available_modes = [] + self._mode = None + self._min_humidity = DEFAULT_MIN_HUMIDITY + self._max_humidity = DEFAULT_MAX_HUMIDITY + self._humidity_steps = 100 + self._target_humidity = None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + @property + def available_modes(self) -> list: + """Get the list of available modes.""" + return self._available_modes + + @property + def mode(self): + """Get the current mode.""" + return self._mode + + @property + def min_humidity(self): + """Return the minimum target humidity.""" + return self._min_humidity + + @property + def max_humidity(self): + """Return the maximum target humidity.""" + return self._max_humidity + + async def async_turn_on( + self, + **kwargs, + ) -> None: + """Turn the device on.""" + result = await self._try_command( + "Turning the miio device on failed.", self._device.on + ) + if result: + self._state = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn the device off.""" + result = await self._try_command( + "Turning the miio device off failed.", self._device.off + ) + + if result: + self._state = False + self.async_write_ha_state() + + def translate_humidity(self, humidity): + """Translate the target humidity to the first valid step.""" + return ( + math.ceil(percentage_to_ranged_value((1, self._humidity_steps), humidity)) + * 100 + / self._humidity_steps + if 0 < humidity <= 100 + else None + ) + + +class XiaomiAirHumidifier(XiaomiGenericHumidifier, HumidifierEntity): + """Representation of a Xiaomi Air Humidifier.""" + + def __init__(self, name, device, entry, unique_id, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator) + if self._model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + self._available_modes = [] + self._available_modes = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Strong + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 10 + elif self._model in [MODEL_AIRHUMIDIFIER_CA4]: + self._available_modes = [ + mode.name for mode in AirhumidifierMiotOperationMode + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 100 + else: + self._available_modes = [ + mode.name + for mode in AirhumidifierOperationMode + if mode is not AirhumidifierOperationMode.Auto + ] + self._min_humidity = 30 + self._max_humidity = 80 + self._humidity_steps = 10 + + self._state = self.coordinator.data.is_on + self._attributes.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in AVAILABLE_ATTRIBUTES.items() + } + ) + self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._mode = self._attributes[ATTR_MODE] + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._state = self.coordinator.data.is_on + self._attributes.update( + { + key: self._extract_value_from_attribute(self.coordinator.data, value) + for key, value in AVAILABLE_ATTRIBUTES.items() + } + ) + self._target_humidity = self._attributes[ATTR_TARGET_HUMIDITY] + self._mode = self._attributes[ATTR_MODE] + self.async_write_ha_state() + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + return ( + self._target_humidity + if self._mode == AirhumidifierOperationMode.Auto.value + or AirhumidifierOperationMode.Auto.name not in self.available_modes + else None + ) + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to auto.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the target humidity to: %s", target_humidity) + if await self._try_command( + "Setting target humidity of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierOperationMode.Auto + or AirhumidifierOperationMode.Auto.name not in self.available_modes + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Auto") + if await self._try_command( + "Setting operation mode of the miio device to MODE_AUTO failed.", + self._device.set_mode, + AirhumidifierOperationMode.Auto, + ): + self._mode = AirhumidifierOperationMode.Auto.value + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the humidifier.""" + if self.supported_features & SUPPORT_MODES == 0 or not mode: + return + + if mode not in self.available_modes: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + AirhumidifierOperationMode[mode], + ): + self._mode = mode.lower() + self.async_write_ha_state() + + +class XiaomiAirHumidifierMiot(XiaomiAirHumidifier): + """Representation of a Xiaomi Air Humidifier (MiOT protocol).""" + + MODE_MAPPING = { + AirhumidifierMiotOperationMode.Auto: "Auto", + AirhumidifierMiotOperationMode.Low: "Low", + AirhumidifierMiotOperationMode.Mid: "Mid", + AirhumidifierMiotOperationMode.High: "High", + } + + REVERSE_MODE_MAPPING = {v: k for k, v in MODE_MAPPING.items()} + + @property + def mode(self): + """Return the current mode.""" + return AirhumidifierMiotOperationMode(self._mode).name + + @property + def target_humidity(self): + """Return the target humidity.""" + if self._state: + return ( + self._target_humidity + if AirhumidifierMiotOperationMode(self._mode) + == AirhumidifierMiotOperationMode.Auto + else None + ) + return None + + async def async_set_humidity(self, humidity: int) -> None: + """Set the target humidity of the humidifier and set the mode to auto.""" + target_humidity = self.translate_humidity(humidity) + if not target_humidity: + return + + _LOGGER.debug("Setting the humidity to: %s", target_humidity) + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_target_humidity, + target_humidity, + ): + self._target_humidity = target_humidity + if ( + self.supported_features & SUPPORT_MODES == 0 + or AirhumidifierMiotOperationMode(self._attributes[ATTR_MODE]) + == AirhumidifierMiotOperationMode.Auto + ): + self.async_write_ha_state() + return + _LOGGER.debug("Setting the operation mode to: Auto") + if await self._try_command( + "Setting operation mode of the miio device to MODE_AUTO failed.", + self._device.set_mode, + AirhumidifierMiotOperationMode.Auto, + ): + self._mode = 0 + self.async_write_ha_state() + + async def async_set_mode(self, mode: str) -> None: + """Set the mode of the fan.""" + if self.supported_features & SUPPORT_MODES == 0 or not mode: + return + + if mode not in self.REVERSE_MODE_MAPPING: + _LOGGER.warning("Mode %s is not a valid operation mode", mode) + return + + _LOGGER.debug("Setting the operation mode to: %s", mode) + if self._state: + if await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, + self.REVERSE_MODE_MAPPING[mode], + ): + self._mode = self.REVERSE_MODE_MAPPING[mode].value + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index f6cd468ad00..6025ae047c6 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -241,10 +241,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if update_tasks: await asyncio.wait(update_tasks) - for xiaomi_miio_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[xiaomi_miio_service].get( - "schema", XIAOMI_MIIO_SERVICE_SCHEMA - ) + for xiaomi_miio_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", XIAOMI_MIIO_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema ) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py new file mode 100644 index 00000000000..6855faa6391 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/number.py @@ -0,0 +1,144 @@ +"""Motor speed support for Xiaomi Mi Air Humidifier.""" +from dataclasses import dataclass +from enum import Enum + +from homeassistant.components.number import NumberEntity +from homeassistant.core import callback + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + FEATURE_SET_MOTOR_SPEED, + KEY_COORDINATOR, + KEY_DEVICE, + MODEL_AIRHUMIDIFIER_CA4, +) +from .device import XiaomiCoordinatedMiioEntity + +ATTR_MOTOR_SPEED = "motor_speed" + + +@dataclass +class NumberType: + """Class that holds device specific info for a xiaomi aqara or humidifier number controller types.""" + + name: str = None + short_name: str = None + unit_of_measurement: str = None + icon: str = None + device_class: str = None + min: float = None + max: float = None + step: float = None + available_with_device_off: bool = True + + +NUMBER_TYPES = { + FEATURE_SET_MOTOR_SPEED: NumberType( + name="Motor Speed", + icon="mdi:fast-forward-outline", + short_name=ATTR_MOTOR_SPEED, + unit_of_measurement="rpm", + min=200, + max=2000, + step=10, + available_with_device_off=False, + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Selectors from a config entry.""" + entities = [] + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + model = config_entry.data[CONF_MODEL] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + + if model not in [MODEL_AIRHUMIDIFIER_CA4]: + return + + for number in NUMBER_TYPES.values(): + entities.append( + XiaomiAirHumidifierNumber( + f"{config_entry.title} {number.name}", + device, + config_entry, + f"{number.short_name}_{config_entry.unique_id}", + number, + coordinator, + ) + ) + + async_add_entities(entities) + + +class XiaomiAirHumidifierNumber(XiaomiCoordinatedMiioEntity, NumberEntity): + """Representation of a generic Xiaomi attribute selector.""" + + def __init__(self, name, device, entry, unique_id, number, coordinator): + """Initialize the generic Xiaomi attribute selector.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._attr_icon = number.icon + self._attr_unit_of_measurement = number.unit_of_measurement + self._attr_min_value = number.min + self._attr_max_value = number.max + self._attr_step = number.step + self._controller = number + self._attr_value = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + + @property + def available(self): + """Return the number controller availability.""" + if ( + super().available + and not self.coordinator.data.is_on + and not self._controller.available_with_device_off + ): + return False + return super().available + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + async def async_set_value(self, value): + """Set an option of the miio device.""" + if ( + self.min_value + and value < self.min_value + or self.max_value + and value > self.max_value + ): + raise ValueError( + f"Value {value} not a valid {self.name} within the range {self.min_value} - {self.max_value}" + ) + if await self.async_set_motor_speed(value): + self._attr_value = value + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + self._attr_value = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + self.async_write_ha_state() + + async def async_set_motor_speed(self, motor_speed: int = 400): + """Set the target motor speed.""" + return await self._try_command( + "Setting the target motor speed of the miio device failed.", + self._device.set_speed, + motor_speed, + ) diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py new file mode 100644 index 00000000000..c5cee6221fa --- /dev/null +++ b/homeassistant/components/xiaomi_miio/select.py @@ -0,0 +1,171 @@ +"""Support led_brightness for Mi Air Humidifier.""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from miio.airhumidifier import LedBrightness as AirhumidifierLedBrightness +from miio.airhumidifier_miot import LedBrightness as AirhumidifierMiotLedBrightness + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import callback + +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_MODEL, + DOMAIN, + FEATURE_SET_LED_BRIGHTNESS, + KEY_COORDINATOR, + KEY_DEVICE, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER, +) +from .device import XiaomiCoordinatedMiioEntity + +ATTR_LED_BRIGHTNESS = "led_brightness" + + +LED_BRIGHTNESS_MAP = {"Bright": 0, "Dim": 1, "Off": 2} +LED_BRIGHTNESS_MAP_MIOT = {"Bright": 2, "Dim": 1, "Off": 0} +LED_BRIGHTNESS_REVERSE_MAP = {val: key for key, val in LED_BRIGHTNESS_MAP.items()} +LED_BRIGHTNESS_REVERSE_MAP_MIOT = { + val: key for key, val in LED_BRIGHTNESS_MAP_MIOT.items() +} + + +@dataclass +class XiaomiMiioSelectDescription(SelectEntityDescription): + """A class that describes select entities.""" + + options: tuple = () + + +SELECTOR_TYPES = { + FEATURE_SET_LED_BRIGHTNESS: XiaomiMiioSelectDescription( + key=ATTR_LED_BRIGHTNESS, + name="Led Brightness", + icon="mdi:brightness-6", + device_class="xiaomi_miio__led_brightness", + options=("bright", "dim", "off"), + ), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Selectors from a config entry.""" + if not config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + return + + entities = [] + model = config_entry.data[CONF_MODEL] + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + + if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + entity_class = XiaomiAirHumidifierSelector + elif model in [MODEL_AIRHUMIDIFIER_CA4]: + entity_class = XiaomiAirHumidifierMiotSelector + elif model in MODELS_HUMIDIFIER: + entity_class = XiaomiAirHumidifierSelector + else: + return + + description = SELECTOR_TYPES[FEATURE_SET_LED_BRIGHTNESS] + entities.append( + entity_class( + f"{config_entry.title} {description.name}", + device, + config_entry, + f"{description.key}_{config_entry.unique_id}", + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + description, + ) + ) + + async_add_entities(entities) + + +class XiaomiSelector(XiaomiCoordinatedMiioEntity, SelectEntity): + """Representation of a generic Xiaomi attribute selector.""" + + def __init__(self, name, device, entry, unique_id, coordinator, description): + """Initialize the generic Xiaomi attribute selector.""" + super().__init__(name, device, entry, unique_id, coordinator) + self._attr_options = list(description.options) + self.entity_description = description + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + +class XiaomiAirHumidifierSelector(XiaomiSelector): + """Representation of a Xiaomi Air Humidifier selector.""" + + def __init__(self, name, device, entry, unique_id, coordinator, description): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator, description) + self._current_led_brightness = self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key + ) + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + self._current_led_brightness = self._extract_value_from_attribute( + self.coordinator.data, self.entity_description.key + ) + self.async_write_ha_state() + + @property + def current_option(self): + """Return the current option.""" + return self.led_brightness.lower() + + async def async_select_option(self, option: str) -> None: + """Set an option of the miio device.""" + if option not in self.options: + raise ValueError( + f"Selection '{option}' is not a valid {self.entity_description.name}" + ) + await self.async_set_led_brightness(option.title()) + + @property + def led_brightness(self): + """Return the current led brightness.""" + return LED_BRIGHTNESS_REVERSE_MAP.get(self._current_led_brightness) + + async def async_set_led_brightness(self, brightness: str): + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirhumidifierLedBrightness(LED_BRIGHTNESS_MAP[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP[brightness] + self.async_write_ha_state() + + +class XiaomiAirHumidifierMiotSelector(XiaomiAirHumidifierSelector): + """Representation of a Xiaomi Air Humidifier (MiOT protocol) selector.""" + + @property + def led_brightness(self): + """Return the current led brightness.""" + return LED_BRIGHTNESS_REVERSE_MAP_MIOT.get(self._current_led_brightness) + + async def async_set_led_brightness(self, brightness: str): + """Set the led brightness.""" + if await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, + AirhumidifierMiotLedBrightness(LED_BRIGHTNESS_MAP_MIOT[brightness]), + ): + self._current_led_brightness = LED_BRIGHTNESS_MAP_MIOT[brightness] + self.async_write_ha_state() diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 5d271a772b9..413971aa880 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -1,5 +1,6 @@ -"""Support for Xiaomi Mi Air Quality Monitor (PM2.5).""" +"""Support for Xiaomi Mi Air Quality Monitor (PM2.5) and Humidifier.""" from dataclasses import dataclass +from enum import Enum import logging from miio import AirQualityMonitor, DeviceException @@ -20,6 +21,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_BATTERY_LEVEL, + ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, CONF_TOKEN, @@ -35,8 +37,17 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from .const import CONF_DEVICE, CONF_FLOW_TYPE, CONF_GATEWAY, DOMAIN, KEY_COORDINATOR -from .device import XiaomiMiioEntity +from .const import ( + CONF_DEVICE, + CONF_FLOW_TYPE, + CONF_GATEWAY, + CONF_MODEL, + DOMAIN, + KEY_COORDINATOR, + KEY_DEVICE, + MODELS_HUMIDIFIER_MIOT, +) +from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) @@ -59,42 +70,69 @@ ATTR_NIGHT_MODE = "night_mode" ATTR_NIGHT_TIME_BEGIN = "night_time_begin" ATTR_NIGHT_TIME_END = "night_time_end" ATTR_SENSOR_STATE = "sensor_state" - -SUCCESS = ["ok"] +ATTR_WATER_LEVEL = "water_level" +ATTR_HUMIDITY = "humidity" +ATTR_ACTUAL_MOTOR_SPEED = "actual_speed" @dataclass class SensorType: - """Class that holds device specific info for a xiaomi aqara sensor.""" + """Class that holds device specific info for a xiaomi aqara or humidifier sensor.""" unit: str = None icon: str = None device_class: str = None state_class: str = None + valid_min_value: float = None + valid_max_value: float = None -GATEWAY_SENSOR_TYPES = { +SENSOR_TYPES = { "temperature": SensorType( unit=TEMP_CELSIUS, - icon=None, device_class=DEVICE_CLASS_TEMPERATURE, state_class=STATE_CLASS_MEASUREMENT, ), "humidity": SensorType( unit=PERCENTAGE, - icon=None, device_class=DEVICE_CLASS_HUMIDITY, state_class=STATE_CLASS_MEASUREMENT, ), "pressure": SensorType( unit=PRESSURE_HPA, - icon=None, device_class=DEVICE_CLASS_PRESSURE, state_class=STATE_CLASS_MEASUREMENT, ), "load_power": SensorType( - unit=POWER_WATT, icon=None, device_class=DEVICE_CLASS_POWER + unit=POWER_WATT, + device_class=DEVICE_CLASS_POWER, ), + "water_level": SensorType( + unit=PERCENTAGE, + icon="mdi:water-check", + state_class=STATE_CLASS_MEASUREMENT, + valid_min_value=0.0, + valid_max_value=100.0, + ), + "actual_speed": SensorType( + unit="rpm", + icon="mdi:fast-forward", + state_class=STATE_CLASS_MEASUREMENT, + valid_min_value=200.0, + valid_max_value=2000.0, + ), +} + +HUMIDIFIER_SENSORS = { + ATTR_HUMIDITY: "humidity", + ATTR_TEMPERATURE: "temperature", +} + +HUMIDIFIER_SENSORS_MIOT = { + ATTR_HUMIDITY: "humidity", + ATTR_TEMPERATURE: "temperature", + ATTR_WATER_LEVEL: "water_level", + ATTR_ACTUAL_MOTOR_SPEED: "actual_speed", } @@ -135,7 +173,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): sub_devices = gateway.devices coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for sub_device in sub_devices.values(): - sensor_variables = set(sub_device.status) & set(GATEWAY_SENSOR_TYPES) + sensor_variables = set(sub_device.status) & set(SENSOR_TYPES) if sensor_variables: entities.extend( [ @@ -145,19 +183,86 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for variable in sensor_variables ] ) - - if config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: + elif config_entry.data[CONF_FLOW_TYPE] == CONF_DEVICE: host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] - name = config_entry.title - unique_id = config_entry.unique_id + model = config_entry.data[CONF_MODEL] + device = None + sensors = [] + if model in MODELS_HUMIDIFIER_MIOT: + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + sensors = HUMIDIFIER_SENSORS_MIOT + elif model.startswith("zhimi.humidifier."): + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + sensors = HUMIDIFIER_SENSORS + else: + unique_id = config_entry.unique_id + name = config_entry.title + _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) - _LOGGER.debug("Initializing with host %s (token %s...)", host, token[:5]) + device = AirQualityMonitor(host, token) + entities.append( + XiaomiAirQualityMonitor(name, device, config_entry, unique_id) + ) + for sensor in sensors: + entities.append( + XiaomiGenericSensor( + f"{config_entry.title} {sensor.replace('_', ' ').title()}", + device, + config_entry, + f"{sensor}_{config_entry.unique_id}", + sensor, + hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR], + ) + ) - device = AirQualityMonitor(host, token) - entities.append(XiaomiAirQualityMonitor(name, device, config_entry, unique_id)) + async_add_entities(entities) - async_add_entities(entities, update_before_add=True) + +class XiaomiGenericSensor(XiaomiCoordinatedMiioEntity, SensorEntity): + """Representation of a Xiaomi Humidifier sensor.""" + + def __init__(self, name, device, entry, unique_id, attribute, coordinator): + """Initialize the entity.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._sensor_config = SENSOR_TYPES[attribute] + self._attr_device_class = self._sensor_config.device_class + self._attr_state_class = self._sensor_config.state_class + self._attr_icon = self._sensor_config.icon + self._attr_name = name + self._attr_unique_id = unique_id + self._attr_unit_of_measurement = self._sensor_config.unit + self._device = device + self._entry = entry + self._attribute = attribute + self._state = None + + @property + def state(self): + """Return the state of the device.""" + self._state = self._extract_value_from_attribute( + self.coordinator.data, self._attribute + ) + if ( + self._sensor_config.valid_min_value + and self._state < self._sensor_config.valid_min_value + ) or ( + self._sensor_config.valid_max_value + and self._state > self._sensor_config.valid_max_value + ): + return None + return self._state + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): @@ -189,7 +294,7 @@ class XiaomiAirQualityMonitor(XiaomiMiioEntity, SensorEntity): @property def icon(self): - """Return the icon to use for device if any.""" + """Return the icon to use in the frontend.""" return self._icon @property @@ -247,22 +352,22 @@ class XiaomiGatewaySensor(XiaomiGatewayDevice, SensorEntity): @property def icon(self): """Return the icon to use in the frontend.""" - return GATEWAY_SENSOR_TYPES[self._data_key].icon + return SENSOR_TYPES[self._data_key].icon @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return GATEWAY_SENSOR_TYPES[self._data_key].unit + return SENSOR_TYPES[self._data_key].unit @property def device_class(self): """Return the device class of this entity.""" - return GATEWAY_SENSOR_TYPES[self._data_key].device_class + return SENSOR_TYPES[self._data_key].device_class @property def state_class(self): """Return the state class of this entity.""" - return GATEWAY_SENSOR_TYPES[self._data_key].state_class + return SENSOR_TYPES[self._data_key].state_class @property def state(self): diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml index 90d31765307..4c153292d7e 100644 --- a/homeassistant/components/xiaomi_miio/services.yaml +++ b/homeassistant/components/xiaomi_miio/services.yaml @@ -211,49 +211,6 @@ fan_set_extra_features: min: 0 max: 1 -fan_set_target_humidity: - name: Fan set target humidity - description: Set the target humidity. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - humidity: - name: Humidity - description: Target humidity. - required: true - selector: - number: - min: 30 - max: 80 - step: 10 - unit_of_measurement: '%' - -fan_set_dry_on: - name: Fan set dry on - description: Turn the dry mode on. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - -fan_set_dry_off: - name: Fan set dry off - description: Turn the dry mode off. - fields: - entity_id: - description: Name of the xiaomi miio entity. - selector: - entity: - integration: xiaomi_miio - domain: fan - fan_set_motor_speed: name: Fan set motor speed description: Set the target motor speed. diff --git a/homeassistant/components/xiaomi_miio/strings.select.json b/homeassistant/components/xiaomi_miio/strings.select.json new file mode 100644 index 00000000000..80edde042ce --- /dev/null +++ b/homeassistant/components/xiaomi_miio/strings.select.json @@ -0,0 +1,9 @@ +{ + "state": { + "xiaomi_miio__led_brightness": { + "bright": "Bright", + "dim": "Dim", + "off": "Off" + } + } + } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py index ace0e52eaea..bdf3085f236 100644 --- a/homeassistant/components/xiaomi_miio/switch.py +++ b/homeassistant/components/xiaomi_miio/switch.py @@ -1,5 +1,7 @@ """Support for Xiaomi Smart WiFi Socket and Smart Power Strip.""" import asyncio +from dataclasses import dataclass +from enum import Enum from functools import partial import logging @@ -21,6 +23,7 @@ from homeassistant.const import ( CONF_NAME, CONF_TOKEN, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( @@ -29,13 +32,30 @@ from .const import ( CONF_GATEWAY, CONF_MODEL, DOMAIN, + FEATURE_FLAGS_AIRHUMIDIFIER, + FEATURE_FLAGS_AIRHUMIDIFIER_CA4, + FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB, + FEATURE_SET_BUZZER, + FEATURE_SET_CHILD_LOCK, + FEATURE_SET_CLEAN, + FEATURE_SET_DRY, KEY_COORDINATOR, + KEY_DEVICE, + MODEL_AIRHUMIDIFIER_CA1, + MODEL_AIRHUMIDIFIER_CA4, + MODEL_AIRHUMIDIFIER_CB1, + MODELS_HUMIDIFIER, + SERVICE_SET_BUZZER, + SERVICE_SET_CHILD_LOCK, + SERVICE_SET_CLEAN, + SERVICE_SET_DRY, SERVICE_SET_POWER_MODE, SERVICE_SET_POWER_PRICE, SERVICE_SET_WIFI_LED_OFF, SERVICE_SET_WIFI_LED_ON, + SUCCESS, ) -from .device import XiaomiMiioEntity +from .device import XiaomiCoordinatedMiioEntity, XiaomiMiioEntity from .gateway import XiaomiGatewayDevice _LOGGER = logging.getLogger(__name__) @@ -83,8 +103,10 @@ ATTR_POWER_MODE = "power_mode" ATTR_WIFI_LED = "wifi_led" ATTR_POWER_PRICE = "power_price" ATTR_PRICE = "price" - -SUCCESS = ["ok"] +ATTR_BUZZER = "buzzer" +ATTR_CHILD_LOCK = "child_lock" +ATTR_DRY = "dry" +ATTR_CLEAN = "clean_mode" FEATURE_SET_POWER_MODE = 1 FEATURE_SET_WIFI_LED = 2 @@ -121,6 +143,62 @@ SERVICE_TO_METHOD = { "method": "async_set_power_price", "schema": SERVICE_SCHEMA_POWER_PRICE, }, + SERVICE_SET_BUZZER: { + "method_on": "async_set_buzzer_on", + "method_off": "async_set_buzzer_off", + }, + SERVICE_SET_CHILD_LOCK: { + "method_on": "async_set_child_lock_on", + "method_off": "async_set_child_lock_off", + }, + SERVICE_SET_DRY: { + "method_on": "async_set_dry_on", + "method_off": "async_set_dry_off", + }, + SERVICE_SET_CLEAN: { + "method_on": "async_set_clean_on", + "method_off": "async_set_clean_off", + }, +} + + +@dataclass +class SwitchType: + """Class that holds device specific info for a xiaomi aqara or humidifiers.""" + + name: str = None + short_name: str = None + icon: str = None + service: str = None + available_with_device_off: bool = True + + +SWITCH_TYPES = { + FEATURE_SET_BUZZER: SwitchType( + name="Buzzer", + icon="mdi:volume-high", + short_name=ATTR_BUZZER, + service=SERVICE_SET_BUZZER, + ), + FEATURE_SET_CHILD_LOCK: SwitchType( + name="Child Lock", + icon="mdi:lock", + short_name=ATTR_CHILD_LOCK, + service=SERVICE_SET_CHILD_LOCK, + ), + FEATURE_SET_DRY: SwitchType( + name="Dry Mode", + icon="mdi:hair-dryer", + short_name=ATTR_DRY, + service=SERVICE_SET_DRY, + ), + FEATURE_SET_CLEAN: SwitchType( + name="Clean Mode", + icon="mdi:sparkles", + short_name=ATTR_CLEAN, + service=SERVICE_SET_CLEAN, + available_with_device_off=False, + ), } @@ -140,14 +218,56 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the switch from a config entry.""" - entities = [] + if config_entry.data[CONF_MODEL] in MODELS_HUMIDIFIER: + await async_setup_coordinated_entry(hass, config_entry, async_add_entities) + else: + await async_setup_other_entry(hass, config_entry, async_add_entities) + +async def async_setup_coordinated_entry(hass, config_entry, async_add_entities): + """Set up the coordinated switch from a config entry.""" + entities = [] + model = config_entry.data[CONF_MODEL] + unique_id = config_entry.unique_id + device = hass.data[DOMAIN][config_entry.entry_id][KEY_DEVICE] + coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} + + device_features = 0 + + if model in [MODEL_AIRHUMIDIFIER_CA1, MODEL_AIRHUMIDIFIER_CB1]: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA_AND_CB + elif model in [MODEL_AIRHUMIDIFIER_CA4]: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA4 + elif model in MODELS_HUMIDIFIER: + device_features = FEATURE_FLAGS_AIRHUMIDIFIER + + for feature, switch in SWITCH_TYPES.items(): + if feature & device_features: + entities.append( + XiaomiGenericCoordinatedSwitch( + f"{config_entry.title} {switch.name}", + device, + config_entry, + f"{switch.short_name}_{unique_id}", + switch, + coordinator, + ) + ) + + async_add_entities(entities) + + +async def async_setup_other_entry(hass, config_entry, async_add_entities): + """Set up the other type switch from a config entry.""" + entities = [] host = config_entry.data[CONF_HOST] token = config_entry.data[CONF_TOKEN] name = config_entry.title model = config_entry.data[CONF_MODEL] unique_id = config_entry.unique_id - if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY: gateway = hass.data[DOMAIN][config_entry.entry_id][CONF_GATEWAY] # Gateway sub devices @@ -181,7 +301,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): # The device has two switchable channels (mains and a USB port). # A switch device per channel will be created. - for channel_usb in [True, False]: + for channel_usb in (True, False): if channel_usb: unique_id_ch = f"{unique_id}-USB" else: @@ -250,13 +370,137 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if update_tasks: await asyncio.wait(update_tasks) - for plug_service in SERVICE_TO_METHOD: - schema = SERVICE_TO_METHOD[plug_service].get("schema", SERVICE_SCHEMA) + for plug_service, method in SERVICE_TO_METHOD.items(): + schema = method.get("schema", SERVICE_SCHEMA) hass.services.async_register( DOMAIN, plug_service, async_service_handler, schema=schema ) - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) + + +class XiaomiGenericCoordinatedSwitch(XiaomiCoordinatedMiioEntity, SwitchEntity): + """Representation of a Xiaomi Plug Generic.""" + + def __init__(self, name, device, entry, unique_id, switch, coordinator): + """Initialize the plug switch.""" + super().__init__(name, device, entry, unique_id, coordinator) + + self._attr_icon = switch.icon + self._controller = switch + self._attr_is_on = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + + @callback + def _handle_coordinator_update(self): + """Fetch state from the device.""" + # On state change the device doesn't provide the new state immediately. + self._attr_is_on = self._extract_value_from_attribute( + self.coordinator.data, self._controller.short_name + ) + self.async_write_ha_state() + + @property + def available(self): + """Return true when state is known.""" + if ( + super().available + and not self.coordinator.data.is_on + and not self._controller.available_with_device_off + ): + return False + return super().available + + @staticmethod + def _extract_value_from_attribute(state, attribute): + value = getattr(state, attribute) + if isinstance(value, Enum): + return value.value + + return value + + async def async_turn_on(self, **kwargs) -> None: + """Turn on an option of the miio device.""" + method = getattr(self, SERVICE_TO_METHOD[self._controller.service]["method_on"]) + if await method(): + # Write state back to avoid switch flips with a slow response + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs) -> None: + """Turn off an option of the miio device.""" + method = getattr( + self, SERVICE_TO_METHOD[self._controller.service]["method_off"] + ) + if await method(): + # Write state back to avoid switch flips with a slow response + self._attr_is_on = False + self.async_write_ha_state() + + async def async_set_buzzer_on(self) -> bool: + """Turn the buzzer on.""" + return await self._try_command( + "Turning the buzzer of the miio device on failed.", + self._device.set_buzzer, + True, + ) + + async def async_set_buzzer_off(self) -> bool: + """Turn the buzzer off.""" + return await self._try_command( + "Turning the buzzer of the miio device off failed.", + self._device.set_buzzer, + False, + ) + + async def async_set_child_lock_on(self) -> bool: + """Turn the child lock on.""" + return await self._try_command( + "Turning the child lock of the miio device on failed.", + self._device.set_child_lock, + True, + ) + + async def async_set_child_lock_off(self) -> bool: + """Turn the child lock off.""" + return await self._try_command( + "Turning the child lock of the miio device off failed.", + self._device.set_child_lock, + False, + ) + + async def async_set_dry_on(self) -> bool: + """Turn the dry mode on.""" + return await self._try_command( + "Turning the dry mode of the miio device on failed.", + self._device.set_dry, + True, + ) + + async def async_set_dry_off(self) -> bool: + """Turn the dry mode off.""" + return await self._try_command( + "Turning the dry mode of the miio device off failed.", + self._device.set_dry, + False, + ) + + async def async_set_clean_on(self) -> bool: + """Turn the dry mode on.""" + return await self._try_command( + "Turning the clean mode of the miio device on failed.", + self._device.set_clean_mode, + True, + ) + + async def async_set_clean_off(self) -> bool: + """Turn the dry mode off.""" + return await self._try_command( + "Turning the clean mode of the miio device off failed.", + self._device.set_clean_mode, + False, + ) class XiaomiGatewaySwitch(XiaomiGatewayDevice, SwitchEntity): diff --git a/homeassistant/components/xiaomi_miio/translations/de.json b/homeassistant/components/xiaomi_miio/translations/de.json index 7f541180a55..17363b347c0 100644 --- a/homeassistant/components/xiaomi_miio/translations/de.json +++ b/homeassistant/components/xiaomi_miio/translations/de.json @@ -5,7 +5,7 @@ "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", "incomplete_info": "Unvollst\u00e4ndige Informationen zur Einrichtung des Ger\u00e4ts, kein Host oder Token geliefert.", "not_xiaomi_miio": "Ger\u00e4t wird (noch) nicht von Xiaomi Miio unterst\u00fctzt.", - "reauth_successful": "[%key::common::config_flow::abort::reauth_successful%]" + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -13,7 +13,7 @@ "cloud_login_error": "Konnte sich nicht bei Xioami Miio Cloud anmelden, \u00fcberpr\u00fcfe die Anmeldedaten.", "cloud_no_devices": "Keine Ger\u00e4te in diesem Xiaomi Miio Cloud-Konto gefunden.", "no_device_selected": "Kein Ger\u00e4t ausgew\u00e4hlt, bitte w\u00e4hle ein Ger\u00e4t aus.", - "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." + "unknown_device": "Das Ger\u00e4temodell ist nicht bekannt und das Ger\u00e4t kann nicht mithilfe des Assistenten eingerichtet werden." }, "flow_title": "{name}", "step": { @@ -41,7 +41,7 @@ "name": "Name des Ger\u00e4ts", "token": "API-Token" }, - "description": "Sie ben\u00f6tigen den 32 Zeichen langen API-Token, siehe https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token f\u00fcr eine Anleitung. Dieser unterscheidet sich vom API-Token, den die Xiaomi Aqara-Integration nutzt.", + "description": "Du ben\u00f6tigst den 32 Zeichen langen API-Token, siehe https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token f\u00fcr eine Anleitung. Dieser unterscheidet sich vom API-Token, den die Xiaomi Aqara-Integration nutzt.", "title": "Herstellen einer Verbindung mit einem Xiaomi Miio-Ger\u00e4t oder Xiaomi Gateway" }, "gateway": { @@ -50,12 +50,12 @@ "name": "Name des Gateways", "token": "API-Token" }, - "description": "Sie ben\u00f6tigen den 32 Zeichen langen API-Token. Anweisungen finden Sie unter https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token.", + "description": "Du ben\u00f6tigst den 32 Zeichen langen API-Token. Anweisungen findest du unter https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token. Bitte beachte, dass sich dieser API-Token von dem Schl\u00fcssel unterscheidet, der von der Xiaomi Aqara Integration verwendet wird.", "title": "Stelle eine Verbindung zu einem Xiaomi Gateway her" }, "manual": { "data": { - "host": "[%key::common::config_flow::data::ip%]", + "host": "IP-Adresse", "token": "API-Token" }, "description": "Du ben\u00f6tigst den 32 Zeichen langen API-Token, siehe https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token f\u00fcr Anweisungen. Bitte beachte, dass sich dieser API-Token von dem Schl\u00fcssel unterscheidet, der von der Xiaomi Aqara-Integration verwendet wird.", @@ -63,7 +63,7 @@ }, "reauth_confirm": { "description": "Die Xiaomi Miio-Integration muss dein Konto neu authentifizieren, um die Token zu aktualisieren oder fehlende Cloud-Anmeldedaten hinzuzuf\u00fcgen.", - "title": "[%key::common::config_flow::title::reauth%]" + "title": "Integration erneut authentifizieren" }, "select": { "data": { @@ -76,7 +76,7 @@ "data": { "gateway": "Stelle eine Verbindung zu einem Xiaomi Gateway her" }, - "description": "W\u00e4hlen Sie aus, mit welchem Ger\u00e4t Sie eine Verbindung herstellen m\u00f6chten.", + "description": "W\u00e4hle aus, mit welchem Ger\u00e4t du eine Verbindung herstellen m\u00f6chtest.", "title": "Xiaomi Miio" } } diff --git a/homeassistant/components/xiaomi_miio/translations/es.json b/homeassistant/components/xiaomi_miio/translations/es.json index 0193cd4a39a..26f5b3937b6 100644 --- a/homeassistant/components/xiaomi_miio/translations/es.json +++ b/homeassistant/components/xiaomi_miio/translations/es.json @@ -57,7 +57,8 @@ "title": "Con\u00e9ctese a un dispositivo Xiaomi Miio o una puerta de enlace Xiaomi" }, "reauth_confirm": { - "description": "La integraci\u00f3n de Xiaomi Miio necesita volver a autenticar tu cuenta para actualizar los tokens o a\u00f1adir las credenciales de la nube que faltan." + "description": "La integraci\u00f3n de Xiaomi Miio necesita volver a autenticar tu cuenta para actualizar los tokens o a\u00f1adir las credenciales de la nube que faltan.", + "title": "Volver a autenticar la integraci\u00f3n" }, "select": { "data": { diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json index 30def127e7a..2b68325a246 100644 --- a/homeassistant/components/xiaomi_miio/translations/fr.json +++ b/homeassistant/components/xiaomi_miio/translations/fr.json @@ -2,15 +2,38 @@ "config": { "abort": { "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", - "already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours." + "already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours.", + "incomplete_info": "Informations incompl\u00e8tes pour configurer l'appareil, aucun h\u00f4te ou jeton fourni.", + "not_xiaomi_miio": "L'appareil n'est pas (encore) pris en charge par Xiaomi Miio.", + "reauth_successful": "R\u00e9-authentification r\u00e9ussie" }, "error": { "cannot_connect": "\u00c9chec de connexion", + "cloud_credentials_incomplete": "Identifiants cloud incomplets, veuillez renseigner le nom d'utilisateur, le mot de passe et le pays", + "cloud_login_error": "Impossible de se connecter \u00e0 Xioami Miio Cloud, v\u00e9rifiez les informations d'identification.", + "cloud_no_devices": "Aucun appareil trouv\u00e9 dans ce compte cloud Xiaomi Miio.", "no_device_selected": "Aucun appareil s\u00e9lectionn\u00e9, veuillez s\u00e9lectionner un appareil.", "unknown_device": "Le mod\u00e8le d'appareil n'est pas connu, impossible de configurer l'appareil \u00e0 l'aide du flux de configuration." }, "flow_title": "Xiaomi Miio: {name}", "step": { + "cloud": { + "data": { + "cloud_country": "Pays du serveur cloud", + "cloud_password": "Mot de passe cloud", + "cloud_username": "Nom d'utilisateur cloud", + "manual": "Configurer manuellement (non recommand\u00e9)" + }, + "description": "Connectez-vous au cloud Xiaomi Miio, voir https://www.openhab.org/addons/bindings/miio/#country-servers pour le serveur cloud \u00e0 utiliser.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, + "connect": { + "data": { + "model": "Mod\u00e8le d'appareil" + }, + "description": "S\u00e9lectionner manuellement le mod\u00e8le d'appareil parmi les mod\u00e8les pris en charge.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, "device": { "data": { "host": "Adresse IP", @@ -30,6 +53,25 @@ "description": "Vous aurez besoin du jeton API, voir https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token pour les instructions.", "title": "Se connecter \u00e0 la passerelle Xiaomi" }, + "manual": { + "data": { + "host": "Adresse IP", + "token": "Jeton API" + }, + "description": "Vous aurez besoin du jeton API de 32 caract\u00e8res, voir https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token pour les instructions. Veuillez noter que ce jeton API est diff\u00e9rent de la cl\u00e9 utilis\u00e9e par l'int\u00e9gration Xiaomi Aqara.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, + "reauth_confirm": { + "description": "L'int\u00e9gration de Xiaomi Miio doit r\u00e9-authentifier votre compte afin de mettre \u00e0 jour les jetons ou d'ajouter les informations d'identification cloud manquantes.", + "title": "R\u00e9authentification de l'int\u00e9gration" + }, + "select": { + "data": { + "select_device": "Appareil Miio" + }, + "description": "S\u00e9lectionner l'appareil Xiaomi Miio \u00e0 configurer.", + "title": "Se connecter \u00e0 un appareil Xiaomi Miio ou \u00e0 une passerelle Xiaomi" + }, "user": { "data": { "gateway": "Se connecter \u00e0 la passerelle Xiaomi" @@ -38,5 +80,19 @@ "title": "Xiaomi Miio" } } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "Identifiants cloud incomplets, veuillez renseigner le nom d'utilisateur, le mot de passe et le pays" + }, + "step": { + "init": { + "data": { + "cloud_subdevices": "Utiliser le cloud pour connecter des sous-appareils" + }, + "description": "Sp\u00e9cifiez les param\u00e8tres optionnels", + "title": "Xiaomi Miio" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/he.json b/homeassistant/components/xiaomi_miio/translations/he.json index 9eb4ffc0bb7..e3bf59f9459 100644 --- a/homeassistant/components/xiaomi_miio/translations/he.json +++ b/homeassistant/components/xiaomi_miio/translations/he.json @@ -3,34 +3,95 @@ "abort": { "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "already_in_progress": "\u05d6\u05e8\u05d9\u05de\u05ea \u05d4\u05ea\u05e6\u05d5\u05e8\u05d4 \u05db\u05d1\u05e8 \u05de\u05ea\u05d1\u05e6\u05e2\u05ea", + "incomplete_info": "\u05de\u05d9\u05d3\u05e2 \u05dc\u05d0 \u05e9\u05dc\u05dd \u05dc\u05d4\u05ea\u05e7\u05e0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df, \u05dc\u05d0 \u05e1\u05d5\u05e4\u05e7\u05d5 \u05de\u05d0\u05e8\u05d7 \u05d0\u05d5 \u05d0\u05e1\u05d9\u05de\u05d5\u05df.", + "not_xiaomi_miio": "\u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05e0\u05ea\u05de\u05da (\u05e2\u05d3\u05d9\u05d9\u05df) \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5.", "reauth_successful": "\u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05d4\u05e6\u05dc\u05d9\u05d7" }, "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "cloud_credentials_incomplete": "\u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05e2\u05e0\u05df \u05d0\u05d9\u05e0\u05dd \u05de\u05dc\u05d0\u05d9\u05dd, \u05e0\u05d0 \u05dc\u05de\u05dc\u05d0 \u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9, \u05e1\u05d9\u05e1\u05de\u05d4 \u05d5\u05de\u05d3\u05d9\u05e0\u05d4", + "cloud_login_error": "\u05dc\u05d0 \u05d4\u05d9\u05ea\u05d4 \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e2\u05e0\u05df \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5, \u05e0\u05d0 \u05dc\u05d1\u05d3\u05d5\u05e7 \u05d0\u05ea \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8\u05d9\u05dd.", + "cloud_no_devices": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d1\u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05e2\u05e0\u05df \u05d4\u05d6\u05d4 \u05e9\u05dc \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5.", + "no_device_selected": "\u05dc\u05d0 \u05e0\u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df, \u05e0\u05d0 \u05d1\u05d7\u05e8 \u05d4\u05ea\u05e7\u05df \u05d0\u05d7\u05d3.", + "unknown_device": "\u05d3\u05d2\u05dd \u05d4\u05d4\u05ea\u05e7\u05df \u05d0\u05d9\u05e0\u05d5 \u05d9\u05d3\u05d5\u05e2, \u05d0\u05d9\u05df \u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05d6\u05e8\u05d9\u05de\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4." }, "flow_title": "{name}", "step": { + "cloud": { + "data": { + "cloud_country": "\u05de\u05d3\u05d9\u05e0\u05ea \u05e9\u05e8\u05ea \u05e2\u05e0\u05df", + "cloud_password": "\u05e1\u05d9\u05e1\u05de\u05ea \u05e2\u05e0\u05df", + "cloud_username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9 \u05e2\u05e0\u05df", + "manual": "\u05d4\u05d2\u05d3\u05e8\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea (\u05dc\u05d0 \u05de\u05d5\u05de\u05dc\u05e5)" + }, + "description": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5 \u05dc\u05e2\u05e0\u05df \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5, \u05e8\u05d0\u05d5 https://www.openhab.org/addons/bindings/miio/#country-servers \u05dc\u05e9\u05d9\u05de\u05d5\u05e9 \u05d1\u05e9\u05e8\u05ea \u05d4\u05e2\u05e0\u05df.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" + }, + "connect": { + "data": { + "model": "\u05d3\u05d2\u05dd \u05d4\u05ea\u05e7\u05df" + }, + "description": "\u05d1\u05d7\u05e8 \u05d9\u05d3\u05e0\u05d9\u05ea \u05d0\u05ea \u05d3\u05d2\u05dd \u05d4\u05d4\u05ea\u05e7\u05df \u05de\u05d4\u05d3\u05d2\u05de\u05d9\u05dd \u05d4\u05e0\u05ea\u05de\u05db\u05d9\u05dd.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" + }, "device": { "data": { "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "model": "\u05d3\u05d2\u05dd \u05d4\u05ea\u05e7\u05df (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)", + "name": "\u05e9\u05dd \u05d4\u05d4\u05ea\u05e7\u05df", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" - } + }, + "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "gateway": { "data": { "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", + "name": "\u05e9\u05dd \u05d4\u05e9\u05e2\u05e8", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" }, - "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara." + "description": "\u05d0\u05ea\u05d4 \u05d6\u05e7\u05d5\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API , \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05db\u05d9 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05d0\u05ea \u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "manual": { "data": { "host": "\u05db\u05ea\u05d5\u05d1\u05ea IP", "token": "\u05d0\u05e1\u05d9\u05de\u05d5\u05df API" - } + }, + "description": "\u05ea\u05d6\u05d3\u05e7\u05e7 \u05dc-32 \u05ea\u05d5\u05d5\u05d9 \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05df API, \u05e8\u05d0\u05d4 https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token \u05dc\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea. \u05e9\u05d9\u05dd \u05dc\u05d1, \u05d6\u05d4 \u05d0\u05e1\u05d9\u05de\u05d5\u05df API \u05e9\u05d5\u05e0\u05d4 \u05de\u05d4\u05de\u05e4\u05ea\u05d7 \u05d4\u05de\u05e9\u05de\u05e9 \u05dc\u05e9\u05d9\u05dc\u05d5\u05d1 Xiaomi Aqara.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" }, "reauth_confirm": { + "description": "\u05d4\u05e9\u05d9\u05dc\u05d5\u05d1 \u05e9\u05dc \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5 \u05e6\u05e8\u05d9\u05db\u05d4 \u05dc\u05d0\u05de\u05ea \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e2\u05d3\u05db\u05df \u05d0\u05ea \u05d4\u05d0\u05e1\u05d9\u05de\u05d5\u05e0\u05d9\u05dd \u05d0\u05d5 \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05e2\u05e0\u05df \u05d7\u05e1\u05e8\u05d9\u05dd.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + }, + "select": { + "data": { + "select_device": "\u05d4\u05ea\u05e7\u05df \u05de\u05d9\u05d5" + }, + "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d4\u05ea\u05e7\u05df \u05e9\u05d9\u05d5\u05d0\u05de\u05d9 \u05de\u05d9\u05d5 \u05dc\u05d4\u05ea\u05e7\u05e0\u05d4.", + "title": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05db\u05e9\u05d9\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5 \u05d0\u05d5 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" + }, + "user": { + "data": { + "gateway": "\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9" + }, + "description": "\u05d1\u05d7\u05e8 \u05dc\u05d0\u05d9\u05d6\u05d4 \u05d4\u05ea\u05e7\u05df \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8.", + "title": "\u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5" + } + } + }, + "options": { + "error": { + "cloud_credentials_incomplete": "\u05d0\u05d9\u05e9\u05d5\u05e8\u05d9 \u05e2\u05e0\u05df \u05d0\u05d9\u05e0\u05dd \u05de\u05dc\u05d0\u05d9\u05dd, \u05e0\u05d0 \u05de\u05dc\u05d0 \u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9, \u05e1\u05d9\u05e1\u05de\u05d4 \u05d5\u05de\u05d3\u05d9\u05e0\u05d4" + }, + "step": { + "init": { + "data": { + "cloud_subdevices": "\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05e2\u05e0\u05df \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05ea\u05ea-\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05de\u05d7\u05d5\u05d1\u05e8\u05d9\u05dd" + }, + "description": "\u05e6\u05d9\u05d9\u05df \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9\u05d5\u05ea", + "title": "\u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05de\u05d9\u05d5" } } } diff --git a/homeassistant/components/xiaomi_miio/translations/hu.json b/homeassistant/components/xiaomi_miio/translations/hu.json index d8bf9dfd866..1747b51c61a 100644 --- a/homeassistant/components/xiaomi_miio/translations/hu.json +++ b/homeassistant/components/xiaomi_miio/translations/hu.json @@ -3,14 +3,37 @@ "abort": { "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", + "incomplete_info": "Az eszk\u00f6z be\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges inform\u00e1ci\u00f3k hi\u00e1nyosak, nincs megadva \u00e1llom\u00e1s vagy token.", + "not_xiaomi_miio": "Az eszk\u00f6zt (m\u00e9g) nem t\u00e1mogatja a Xiaomi Miio integr\u00e1ci\u00f3.", "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt" }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "no_device_selected": "Nincs kiv\u00e1lasztva eszk\u00f6z, k\u00e9rj\u00fck, v\u00e1lasszon egyet." + "cloud_credentials_incomplete": "A felh\u0151alap\u00fa hiteles\u00edt\u0151 adatok hi\u00e1nyosak, k\u00e9rj\u00fck, adja meg a felhaszn\u00e1l\u00f3nevet, a jelsz\u00f3t \u00e9s az orsz\u00e1got", + "cloud_login_error": "Nem siker\u00fclt bejelentkezni a Xioami Miio Cloud szolg\u00e1ltat\u00e1sba, ellen\u0151rizze a hiteles\u00edt\u0151 adatokat.", + "cloud_no_devices": "Nincs eszk\u00f6z ebben a Xiaomi Miio felh\u0151fi\u00f3kban.", + "no_device_selected": "Nincs kiv\u00e1lasztva eszk\u00f6z, k\u00e9rj\u00fck, v\u00e1lasszon egyet.", + "unknown_device": "Az eszk\u00f6z modell nem ismert, nem tudja be\u00e1ll\u00edtani az eszk\u00f6zt a konfigur\u00e1ci\u00f3s folyamat seg\u00edts\u00e9g\u00e9vel." }, "flow_title": "{name}", "step": { + "cloud": { + "data": { + "cloud_country": "Felh\u0151kiszolg\u00e1l\u00f3 orsz\u00e1ga", + "cloud_password": "Felh\u0151 jelszava", + "cloud_username": "Felh\u0151 felhaszn\u00e1l\u00f3neve", + "manual": "Konfigur\u00e1l\u00e1s manu\u00e1lisan (nem aj\u00e1nlott)" + }, + "description": "Jelentkezzen be a Xiaomi Miio felh\u0151be, a felh\u0151szerver haszn\u00e1lat\u00e1hoz l\u00e1sd: https://www.openhab.org/addons/bindings/miio/#country-servers.", + "title": "Csatlakozzon egy Xiaomi Miio eszk\u00f6zh\u00f6z vagy a Xiaomi Gateway-hez" + }, + "connect": { + "data": { + "model": "Eszk\u00f6z modell" + }, + "description": "V\u00e1lassza ki manu\u00e1lisan a modellt a t\u00e1mogatott modellek k\u00f6z\u00fcl.", + "title": "Csatlakozzon egy Xiaomi Miio eszk\u00f6zh\u00f6z vagy a Xiaomi Gateway-hez" + }, "device": { "data": { "host": "IP c\u00edm", @@ -34,15 +57,20 @@ "data": { "host": "IP c\u00edm", "token": "API Token" - } + }, + "description": "Sz\u00fcks\u00e9ge lesz a 32 karakteres API Token -re. Az utas\u00edt\u00e1sok\u00e9rt l\u00e1sd: https://www.home-assistant.io/integrations/xiaomi_miio#retrieving-the-access-token. Felh\u00edvjuk figyelm\u00e9t, hogy ez a API Token elt\u00e9r a Xiaomi Aqara integr\u00e1ci\u00f3 \u00e1ltal haszn\u00e1lt kulcst\u00f3l.", + "title": "Csatlakozzon egy Xiaomi Miio eszk\u00f6zh\u00f6z vagy a Xiaomi Gateway-hez" }, "reauth_confirm": { + "description": "A tokenek friss\u00edt\u00e9s\u00e9hez vagy hi\u00e1nyz\u00f3 felh\u0151alap\u00fa hiteles\u00edt\u0151 adatok hozz\u00e1ad\u00e1s\u00e1hoz a Xiaomi Miio integr\u00e1ci\u00f3nak \u00fajra hiteles\u00edtenie kell a fi\u00f3kj\u00e1t.", "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" }, "select": { "data": { "select_device": "Miio eszk\u00f6z" - } + }, + "description": "V\u00e1lassza ki a be\u00e1ll\u00edtand\u00f3 Xiaomi Miio eszk\u00f6zt.", + "title": "Csatlakoz\u00e1s Xiaomi Miio eszk\u00f6zh\u00f6z vagy Xiaomi Gateway-hez" }, "user": { "data": { @@ -54,8 +82,15 @@ } }, "options": { + "error": { + "cloud_credentials_incomplete": "A felh\u0151alap\u00fa hiteles\u00edt\u0151 adatok hi\u00e1nyosak, k\u00e9rj\u00fck, adja meg a felhaszn\u00e1l\u00f3nevet, a jelsz\u00f3t \u00e9s az orsz\u00e1got" + }, "step": { "init": { + "data": { + "cloud_subdevices": "Haszn\u00e1lja a felh\u0151t a csatlakoztatott alegys\u00e9gek megszerz\u00e9s\u00e9hez" + }, + "description": "Adja meg az opcion\u00e1lis be\u00e1ll\u00edt\u00e1sokat", "title": "Xiaomi Miio" } } diff --git a/homeassistant/components/xiaomi_miio/translations/id.json b/homeassistant/components/xiaomi_miio/translations/id.json index d55e19980a7..f893f7b06aa 100644 --- a/homeassistant/components/xiaomi_miio/translations/id.json +++ b/homeassistant/components/xiaomi_miio/translations/id.json @@ -11,6 +11,23 @@ }, "flow_title": "Xiaomi Miio: {name}", "step": { + "cloud": { + "data": { + "cloud_country": "Negara server cloud", + "cloud_password": "Kata sandi cloud", + "cloud_username": "Nama pengguna cloud", + "manual": "Konfigurasi secara manual (tidak disarankan)" + }, + "description": "Masuk ke cloud Xiaomi Miio, lihat https://www.openhab.org/addons/bindings/miio/#country-servers untuk menemukan server cloud yang digunakan.", + "title": "Hubungkan ke Perangkat Xiaomi Miio atau Xiaomi Gateway" + }, + "connect": { + "data": { + "model": "Model perangkat" + }, + "description": "Pilih model perangkat secara manual dari model yang didukung.", + "title": "Hubungkan ke Perangkat Xiaomi Miio atau Xiaomi Gateway" + }, "device": { "data": { "host": "Alamat IP", @@ -30,6 +47,23 @@ "description": "Anda akan membutuhkan Token API 32 karakter, lihat https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token untuk mendapatkan petunjuknya. Perhatikan bahwa Token API ini berbeda dari kunci yang digunakan oleh integrasi Xiaomi Aqara.", "title": "Hubungkan ke Xiaomi Gateway" }, + "manual": { + "data": { + "host": "Alamat IP", + "token": "Token API" + }, + "title": "Hubungkan ke Perangkat Xiaomi Miio atau Xiaomi Gateway" + }, + "reauth_confirm": { + "title": "Autentikasi Ulang Integrasi" + }, + "select": { + "data": { + "select_device": "Perangkat Miio" + }, + "description": "Pilih perangkat Xiaomi Miio untuk disiapkan.", + "title": "Hubungkan ke Perangkat Xiaomi Miio atau Xiaomi Gateway" + }, "user": { "data": { "gateway": "Hubungkan ke Xiaomi Gateway" @@ -38,5 +72,16 @@ "title": "Xiaomi Miio" } } + }, + "options": { + "step": { + "init": { + "data": { + "cloud_subdevices": "Gunakan cloud untuk mendapatkan subperangkat yang tersambung" + }, + "description": "Tentukan pengaturan opsional", + "title": "Xiaomi Miio" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py index bc5ebf12f75..29624f37ffa 100644 --- a/homeassistant/components/xmpp/notify.py +++ b/homeassistant/components/xmpp/notify.py @@ -190,7 +190,9 @@ async def async_send_message( # noqa: C901 _LOGGER.info("Sending file to %s", recipient) message = self.Message(sto=recipient, stype="chat") message["body"] = url - message["oob"]["url"] = url + message["oob"][ # pylint: disable=invalid-sequence-index + "url" + ] = url try: message.send() except (IqError, IqTimeout, XMPPError) as ex: diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 2ce2fb13495..87bfb2b86d7 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -1 +1,46 @@ """The yale_smart_alarm component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import YaleDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yale from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + title = entry.title + + coordinator = YaleDataUpdateCoordinator(hass, entry=entry) + + if not await hass.async_add_executor_job(coordinator.get_updates): + raise ConfigEntryAuthFailed + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + LOGGER.debug("Loaded entry for %s", title) + + 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) + + title = entry.title + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + LOGGER.debug("Unloaded entry for %s", title) + return unload_ok + + return False diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 13433086879..f450895f5c3 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -1,14 +1,7 @@ -"""Component for interacting with the Yale Smart Alarm System API.""" -import logging +"""Support for Yale Alarm.""" +from __future__ import annotations import voluptuous as vol -from yalesmartalarmclient.client import ( - YALE_STATE_ARM_FULL, - YALE_STATE_ARM_PARTIAL, - YALE_STATE_DISARM, - AuthenticationError, - YaleSmartAlarmClient, -) from homeassistant.components.alarm_control_panel import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, @@ -18,23 +11,38 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, ) +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + ConfigType, + DiscoveryInfoType, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -CONF_AREA_ID = "area_id" - -DEFAULT_NAME = "Yale Smart Alarm" - -DEFAULT_AREA_ID = "1" - -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_AREA_ID, + COORDINATOR, + DEFAULT_AREA_ID, + DEFAULT_NAME, + DOMAIN, + LOGGER, + MANUFACTURER, + MODEL, + STATE_MAP, +) +from .coordinator import YaleDataUpdateCoordinator PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { @@ -46,66 +54,82 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the alarm platform.""" - name = config[CONF_NAME] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - area_id = config[CONF_AREA_ID] - - try: - client = YaleSmartAlarmClient(username, password, area_id) - except AuthenticationError: - _LOGGER.error("Authentication failed. Check credentials") - return - - add_entities([YaleAlarmDevice(name, client)], True) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Import Yale configuration from YAML.""" + LOGGER.warning( + "Loading Yale Alarm via platform setup is deprecated; Please remove it from your configuration" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) -class YaleAlarmDevice(AlarmControlPanelEntity): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the alarm entry.""" + + async_add_entities( + [YaleAlarmDevice(coordinator=hass.data[DOMAIN][entry.entry_id][COORDINATOR])] + ) + + +class YaleAlarmDevice(CoordinatorEntity, AlarmControlPanelEntity): """Represent a Yale Smart Alarm.""" - def __init__(self, name, client): - """Initialize the Yale Alarm Device.""" - self._name = name - self._client = client - self._state = None + coordinator: YaleDataUpdateCoordinator - self._state_map = { - YALE_STATE_DISARM: STATE_ALARM_DISARMED, - YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, - YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY, - } + _attr_name: str = coordinator.entry.data[CONF_NAME] + _attr_unique_id: str = coordinator.entry.entry_id + _identifier: str = coordinator.entry.data[CONF_USERNAME] @property - def name(self): - """Return the name of the device.""" - return self._name + def device_info(self) -> DeviceInfo: + """Return device information about this entity.""" + return { + ATTR_NAME: str(self.name), + ATTR_MANUFACTURER: MANUFACTURER, + ATTR_MODEL: MODEL, + ATTR_IDENTIFIERS: {(DOMAIN, self._identifier)}, + } @property def state(self): """Return the state of the device.""" - return self._state + return STATE_MAP.get(self.coordinator.data["alarm"]) + + @property + def available(self): + """Return if entity is available.""" + return STATE_MAP.get(self.coordinator.data["alarm"]) is not None + + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return False @property def supported_features(self) -> int: """Return the list of supported features.""" return SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_AWAY - def update(self): - """Return the state of the device.""" - armed_status = self._client.get_armed_status() - - self._state = self._state_map.get(armed_status) - def alarm_disarm(self, code=None): """Send disarm command.""" - self._client.disarm() + self.coordinator.yale.disarm() def alarm_arm_home(self, code=None): """Send arm home command.""" - self._client.arm_partial() + self.coordinator.yale.arm_partial() def alarm_arm_away(self, code=None): """Send arm away command.""" - self._client.arm_full() + self.coordinator.yale.arm_full() diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py new file mode 100644 index 00000000000..7538c6e40ca --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -0,0 +1,128 @@ +"""Adds config flow for Yale Smart Alarm integration.""" +from __future__ import annotations + +import voluptuous as vol +from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv + +from .const import CONF_AREA_ID, DEFAULT_AREA_ID, DEFAULT_NAME, DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string, + } +) + +DATA_SCHEMA_AUTH = vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class YaleConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Yale integration.""" + + VERSION = 1 + + entry: config_entries.ConfigEntry + + async def async_step_import(self, config: dict): + """Import a configuration from config.yaml.""" + + self.context.update( + {"title_placeholders": {CONF_NAME: f"YAML import {DOMAIN}"}} + ) + return await self.async_step_user(user_input=config) + + async def async_step_reauth(self, user_input=None): + """Handle initiation of re-authentication with Yale.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm(self, user_input=None): + """Dialog that informs the user that reauth is required.""" + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + try: + await self.hass.async_add_executor_job( + YaleSmartAlarmClient, username, password + ) + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + + existing_entry = await self.async_set_unique_id(username) + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, + data={ + **self.entry.data, + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA_AUTH, + errors=errors, + ) + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + name = user_input.get(CONF_NAME, DEFAULT_NAME) + area = user_input.get(CONF_AREA_ID, DEFAULT_AREA_ID) + + try: + await self.hass.async_add_executor_job( + YaleSmartAlarmClient, username, password + ) + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors={"base": "invalid_auth"}, + ) + + await self.async_set_unique_id(username) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=username, + data={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + CONF_NAME: name, + CONF_AREA_ID: area, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py new file mode 100644 index 00000000000..618f9ad073a --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -0,0 +1,39 @@ +"""Yale integration constants.""" +import logging + +from yalesmartalarmclient.client import ( + YALE_STATE_ARM_FULL, + YALE_STATE_ARM_PARTIAL, + YALE_STATE_DISARM, +) + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, +) + +CONF_AREA_ID = "area_id" +DEFAULT_NAME = "Yale Smart Alarm" +DEFAULT_AREA_ID = "1" + +MANUFACTURER = "Yale" +MODEL = "main" + +DOMAIN = "yale_smart_alarm" +COORDINATOR = "coordinator" + +DEFAULT_SCAN_INTERVAL = 15 + +LOGGER = logging.getLogger(__name__) + +ATTR_ONLINE = "online" +ATTR_STATUS = "status" + +PLATFORMS = ["alarm_control_panel"] + +STATE_MAP = { + YALE_STATE_DISARM: STATE_ALARM_DISARMED, + YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME, + YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY, +} diff --git a/homeassistant/components/yale_smart_alarm/coordinator.py b/homeassistant/components/yale_smart_alarm/coordinator.py new file mode 100644 index 00000000000..8b09507e956 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/coordinator.py @@ -0,0 +1,139 @@ +"""DataUpdateCoordinator for the Yale integration.""" +from __future__ import annotations + +from datetime import timedelta + +from yalesmartalarmclient.client import AuthenticationError, YaleSmartAlarmClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class YaleDataUpdateCoordinator(DataUpdateCoordinator): + """A Yale Data Update Coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the Yale hub.""" + self.entry = entry + self.yale: YaleSmartAlarmClient | None = None + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + + async def _async_update_data(self) -> dict: + """Fetch data from Yale.""" + + updates = await self.hass.async_add_executor_job(self.get_updates) + + locks = [] + door_windows = [] + + for device in updates["cycle"]["device_status"]: + state = device["status1"] + if device["type"] == "device_type.door_lock": + lock_status_str = device["minigw_lock_status"] + lock_status = int(str(lock_status_str or 0), 16) + closed = (lock_status & 16) == 16 + locked = (lock_status & 1) == 1 + if not lock_status and "device_status.lock" in state: + device["_state"] = "locked" + locks.append(device) + continue + if not lock_status and "device_status.unlock" in state: + device["_state"] = "unlocked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and closed + and locked + ): + device["_state"] = "locked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and closed + and not locked + ): + device["_state"] = "unlocked" + locks.append(device) + continue + if ( + lock_status + and ( + "device_status.lock" in state or "device_status.unlock" in state + ) + and not closed + ): + device["_state"] = "unlocked" + locks.append(device) + continue + device["_state"] = "unavailable" + locks.append(device) + continue + if device["type"] == "device_type.door_contact": + if "device_status.dc_close" in state: + device["_state"] = "closed" + door_windows.append(device) + continue + if "device_status.dc_open" in state: + device["_state"] = "open" + door_windows.append(device) + continue + device["_state"] = "unavailable" + door_windows.append(device) + continue + + return { + "alarm": updates["arm_status"], + "locks": locks, + "door_windows": door_windows, + "status": updates["status"], + "online": updates["online"], + } + + def get_updates(self) -> dict: + """Fetch data from Yale.""" + + if self.yale is None: + self.yale = YaleSmartAlarmClient( + self.entry.data[CONF_USERNAME], self.entry.data[CONF_PASSWORD] + ) + + try: + arm_status = self.yale.get_armed_status() + cycle = self.yale.get_cycle() + status = self.yale.get_status() + online = self.yale.get_online() + + except AuthenticationError as error: + LOGGER.error("Authentication failed. Check credentials %s", error) + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": self.entry.entry_id}, + data=self.entry.data, + ) + ) + raise UpdateFailed from error + + return { + "arm_status": arm_status, + "cycle": cycle, + "status": status, + "online": online, + } diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json index e900f4e0373..a61a1888990 100644 --- a/homeassistant/components/yale_smart_alarm/manifest.json +++ b/homeassistant/components/yale_smart_alarm/manifest.json @@ -2,7 +2,8 @@ "domain": "yale_smart_alarm", "name": "Yale Smart Living", "documentation": "https://www.home-assistant.io/integrations/yale_smart_alarm", - "requirements": ["yalesmartalarmclient==0.3.3"], + "requirements": ["yalesmartalarmclient==0.3.4"], "codeowners": ["@gjohansson-ST"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json new file mode 100644 index 00000000000..4fb61f5a5f1 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "name": "[%key:common::config_flow::data::name%]", + "area_id": "Area ID" + } + }, + "reauth_confirm": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "name": "[%key:common::config_flow::data::name%]", + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]" + } + } + } + } +} diff --git a/homeassistant/components/yale_smart_alarm/translations/ca.json b/homeassistant/components/yale_smart_alarm/translations/ca.json new file mode 100644 index 00000000000..ab77170999b --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/ca.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID d'\u00e0rea", + "name": "Nom", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + }, + "user": { + "data": { + "area_id": "ID d'\u00e0rea", + "name": "Nom", + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/cs.json b/homeassistant/components/yale_smart_alarm/translations/cs.json new file mode 100644 index 00000000000..f19158bca25 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/cs.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u00da\u010det je ji\u017e nastaven" + }, + "error": { + "invalid_auth": "Neplatn\u00e9 ov\u011b\u0159en\u00ed" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + }, + "user": { + "data": { + "area_id": "[%key:component::yale_smart_alarm::config::step::user::data::area_id%]", + "name": "[%key:component::yale_smart_alarm::config::step::user::data::name%]", + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/de.json b/homeassistant/components/yale_smart_alarm/translations/de.json new file mode 100644 index 00000000000..b3434a70b7e --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/de.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "error": { + "invalid_auth": "Ung\u00fcltige Authentifizierung" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Bereichs-ID", + "name": "Name", + "password": "Passwort", + "username": "Benutzername" + } + }, + "user": { + "data": { + "area_id": "Bereichs-ID", + "name": "Name", + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/en.json b/homeassistant/components/yale_smart_alarm/translations/en.json new file mode 100644 index 00000000000..a439971fb3f --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/en.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "invalid_auth": "Invalid authentication" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Area ID", + "name": "Name", + "password": "Password", + "username": "Username" + } + }, + "user": { + "data": { + "area_id": "Area ID", + "name": "Name", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/et.json b/homeassistant/components/yale_smart_alarm/translations/et.json new file mode 100644 index 00000000000..e773e628d1e --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/et.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto on juba seadistatud" + }, + "error": { + "invalid_auth": "Tuvastamine nurjus" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Tsooni ID", + "name": "Nimi", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + }, + "user": { + "data": { + "area_id": "Tsooni ID", + "name": "Nimi", + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/fr.json b/homeassistant/components/yale_smart_alarm/translations/fr.json new file mode 100644 index 00000000000..60d6f5cc548 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/fr.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "invalid_auth": "Authentification incorrecte" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID de la zone", + "name": "Nom", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + }, + "user": { + "data": { + "area_id": "ID de la zone", + "name": "Nom", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/he.json b/homeassistant/components/yale_smart_alarm/translations/he.json new file mode 100644 index 00000000000..41f5d4493bf --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/he.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u05de\u05d6\u05d4\u05d4 \u05d0\u05d6\u05d5\u05e8", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + }, + "user": { + "data": { + "area_id": "\u05de\u05d6\u05d4\u05d4 \u05d0\u05d6\u05d5\u05e8", + "name": "\u05e9\u05dd", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/it.json b/homeassistant/components/yale_smart_alarm/translations/it.json new file mode 100644 index 00000000000..2f510e46396 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "invalid_auth": "Autenticazione non valida" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID area", + "name": "Nome", + "password": "Password", + "username": "Nome utente" + } + }, + "user": { + "data": { + "area_id": "ID area", + "name": "Nome", + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/nl.json b/homeassistant/components/yale_smart_alarm/translations/nl.json new file mode 100644 index 00000000000..53c1b8fb086 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/nl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "error": { + "invalid_auth": "Ongeldige authenticatie" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Area ID", + "name": "Naam", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + }, + "user": { + "data": { + "area_id": "Area ID", + "name": "Naam", + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/no.json b/homeassistant/components/yale_smart_alarm/translations/no.json new file mode 100644 index 00000000000..bbeedb7dc89 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "name": "Navn", + "password": "Passord", + "username": "Brukernavn" + } + }, + "user": { + "data": { + "name": "Navn", + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/pl.json b/homeassistant/components/yale_smart_alarm/translations/pl.json new file mode 100644 index 00000000000..553d05ee439 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/pl.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "invalid_auth": "Niepoprawne uwierzytelnienie" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "Identyfikator obszaru", + "name": "Nazwa", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + }, + "user": { + "data": { + "area_id": "Identyfikator obszaru", + "name": "[%key::common::config_flow::data::name%]", + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/ru.json b/homeassistant/components/yale_smart_alarm/translations/ru.json new file mode 100644 index 00000000000..aedf07d030e --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant." + }, + "error": { + "invalid_auth": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438." + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + }, + "user": { + "data": { + "area_id": "ID \u043f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u044f", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json new file mode 100644 index 00000000000..e02b74f27a1 --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/zh-Hant.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548" + }, + "step": { + "reauth_confirm": { + "data": { + "area_id": "\u5206\u5340 ID", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + }, + "user": { + "data": { + "area_id": "\u5206\u5340 ID", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index 06bb212e639..9645be3ddc8 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from urllib.parse import urlparse from aiohttp import ClientConnectorError @@ -13,7 +14,6 @@ from homeassistant.components import ssdp from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -29,7 +29,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): host: str async def async_step_user( - self, user_input: ConfigType | None = None + self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle a flow initiated by the user.""" # Request user input, unless we are preparing discovery flow diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 501e3b8a00b..46fae870e5e 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", "requirements": [ - "aiomusiccast==0.8.0" + "aiomusiccast==0.8.2" ], "ssdp": [ { diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index b67aa834008..d08ba798bd8 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -34,11 +34,12 @@ from homeassistant.const import ( STATE_PAUSED, STATE_PLAYING, ) +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType, HomeAssistantType +from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.util import uuid from . import MusicCastDataUpdateCoordinator, MusicCastDeviceEntity @@ -78,7 +79,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform( - hass: HomeAssistantType, + hass: HomeAssistant, config, async_add_devices: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, @@ -106,7 +107,7 @@ async def async_setup_platform( async def async_setup_entry( - hass: HomeAssistantType, + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: @@ -148,22 +149,28 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self._cur_track = 0 self._repeat = REPEAT_MODE_OFF - self.coordinator.entities.append(self) async def async_added_to_hass(self): """Run when this Entity has been added to HA.""" await super().async_added_to_hass() + self.coordinator.entities.append(self) # Sensors should also register callbacks to HA when their state changes self.coordinator.musiccast.register_callback(self.async_write_ha_state) self.coordinator.musiccast.register_group_update_callback( self.update_all_mc_entities ) + self.coordinator.async_add_listener(self.async_schedule_check_client_list) async def async_will_remove_from_hass(self): """Entity being removed from hass.""" await super().async_will_remove_from_hass() + self.coordinator.entities.remove(self) # The opposite of async_added_to_hass. Remove any registered call backs here. self.coordinator.musiccast.remove_callback(self.async_write_ha_state) + self.coordinator.musiccast.remove_group_update_callback( + self.update_all_mc_entities + ) + self.coordinator.async_remove_listener(self.async_schedule_check_client_list) @property def should_poll(self): @@ -509,10 +516,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): def get_distribution_num(self) -> int: """Return the distribution_num (number of clients in the whole musiccast system).""" return sum( - [ - len(server.coordinator.data.group_client_list) - for server in self.get_all_server_entities() - ] + len(server.coordinator.data.group_client_list) + for server in self.get_all_server_entities() ) def is_part_of_group(self, group_server) -> bool: @@ -573,12 +578,18 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): return self - async def update_all_mc_entities(self): + async def update_all_mc_entities(self, check_clients=False): """Update the whole musiccast system when group data change.""" - for entity in self.get_all_mc_entities(): - if entity.is_server: + # First update all servers as they provide the group information for their clients + for entity in self.get_all_server_entities(): + if check_clients or self.coordinator.musiccast.group_reduce_by_source: await entity.async_check_client_list() - entity.async_write_ha_state() + else: + entity.async_write_ha_state() + # Then update all other entities + for entity in self.get_all_mc_entities(): + if not entity.is_server: + entity.async_write_ha_state() # Services @@ -587,7 +598,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): Creates a new group if necessary. Used for join service. """ - _LOGGER.info( + _LOGGER.debug( "%s wants to add the following entities %s", self.entity_id, str(group_members), @@ -599,6 +610,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if entity.entity_id in group_members ] + if self.state == STATE_OFF: + await self.async_turn_on() + if not self.is_server and self.musiccast_zone_entity.is_server: # The MusicCast Distribution Module of this device is already in use. To use it as a server, we first # have to unjoin and wait until the servers are updated. @@ -611,38 +625,40 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if self.is_server else uuid.random_uuid_hex().upper() ) + + ip_addresses = set() # First let the clients join for client in entities: if client != self: try: - await client.async_client_join(group, self) + network_join = await client.async_client_join(group, self) except MusicCastGroupException: _LOGGER.warning( "%s is struggling to update its group data. Will retry perform the update", client.entity_id, ) - await client.async_client_join(group, self) + network_join = await client.async_client_join(group, self) - await self.coordinator.musiccast.mc_server_group_extend( - self._zone_id, - [ - entity.ip_address - for entity in entities - if entity.ip_address != self.ip_address - ], - group, - self.get_distribution_num(), - ) + if network_join: + ip_addresses.add(client.ip_address) + + if ip_addresses: + await self.coordinator.musiccast.mc_server_group_extend( + self._zone_id, + list(ip_addresses), + group, + self.get_distribution_num(), + ) _LOGGER.debug( "%s added the following entities %s", self.entity_id, str(entities) ) - _LOGGER.info( + _LOGGER.debug( "%s has now the following musiccast group %s", self.entity_id, str(self.musiccast_group), ) - await self.update_all_mc_entities() + await self.update_all_mc_entities(True) async def async_unjoin_player(self): """Leave the group. @@ -656,15 +672,15 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): else: await self.async_client_leave_group() - await self.update_all_mc_entities() + await self.update_all_mc_entities(True) # Internal client functions - async def async_client_join(self, group_id, server): + async def async_client_join(self, group_id, server) -> bool: """Let the client join a group. If this client is a server, the server will stop distributing. If the client is part of a different group, - it will leave that group first. + it will leave that group first. Returns True, if the server has to add the client on his side. """ # If we should join the group, which is served by the main zone, we can simply select main_sync as input. _LOGGER.debug("%s called service client join", self.entity_id) @@ -674,14 +690,16 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if server.zone == DEFAULT_ZONE: await self.async_select_source(ATTR_MAIN_SYNC) server.async_write_ha_state() - return + return False # It is not possible to join a group hosted by zone2 from main zone. - raise Exception("Can not join a zone other than main of the same device.") + raise HomeAssistantError( + "Can not join a zone other than main of the same device." + ) if self.musiccast_zone_entity.is_server: # If one of the zones of the device is a server, we need to unjoin first. - _LOGGER.info( + _LOGGER.debug( "%s is a server of a group and has to stop distribution " "to use MusicCast for %s", self.musiccast_zone_entity.entity_id, @@ -690,11 +708,11 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): await self.musiccast_zone_entity.async_server_close_group() elif self.is_client: - if self.coordinator.data.group_id == server.coordinator.data.group_id: + if self.is_part_of_group(server): _LOGGER.warning("%s is already part of the group", self.entity_id) - return + return False - _LOGGER.info( + _LOGGER.debug( "%s is client in a different group, will unjoin first", self.entity_id, ) @@ -707,20 +725,14 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): ): # The device is already part of this group (e.g. main zone is also a client of this group). # Just select mc_link as source - await self.async_select_source(ATTR_MC_LINK) - # As the musiccast group has changed, we need to trigger the servers ha state. - # In other cases this happens due to the callback after the dist updated message. - server.async_write_ha_state() - return + await self.coordinator.musiccast.zone_join(self._zone_id) + return False _LOGGER.debug("%s will now join as a client", self.entity_id) await self.coordinator.musiccast.mc_client_join( server.ip_address, group_id, self._zone_id ) - - # Ensure that mc link is selected. If main sync was selected previously, it's possible that this does not - # happen automatically - await self.async_select_source(ATTR_MC_LINK) + return True async def async_client_leave_group(self, force=False): """Make self leave the group. @@ -730,18 +742,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): _LOGGER.debug("%s client leave called", self.entity_id) if not force and ( self.source == ATTR_MAIN_SYNC - or len( - [entity for entity in self.other_zones if entity.source == ATTR_MC_LINK] - ) - > 0 + or [entity for entity in self.other_zones if entity.source == ATTR_MC_LINK] ): - # If we are only syncing to main or another zone is also using the musiccast module as client, don't - # kill the client session, just select a dummy source. - save_inputs = self.coordinator.musiccast.get_save_inputs(self._zone_id) - if len(save_inputs): - await self.async_select_source(save_inputs[0]) - # Then turn off the zone - await self.async_turn_off() + await self.coordinator.musiccast.zone_unjoin(self._zone_id) else: servers = [ server @@ -749,14 +752,11 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): if server.coordinator.data.group_id == self.coordinator.data.group_id ] await self.coordinator.musiccast.mc_client_unjoin() - if len(servers): + if servers: await servers[0].coordinator.musiccast.mc_server_group_reduce( servers[0].zone_id, [self.ip_address], self.get_distribution_num() ) - for server in self.get_all_server_entities(): - await server.async_check_client_list() - # Internal server functions async def async_server_close_group(self): @@ -764,7 +764,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): Should only be called for servers. """ - _LOGGER.info("%s closes his group", self.entity_id) + _LOGGER.debug("%s closes his group", self.entity_id) for client in self.musiccast_group: if client != self: await client.async_client_leave_group() @@ -772,6 +772,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): async def async_check_client_list(self): """Let the server check if all its clients are still part of his group.""" + if not self.is_server or self.coordinator.data.group_update_lock.locked(): + return + _LOGGER.debug("%s updates his group members", self.entity_id) client_ips_for_removal = [] for expected_client_ip in self.coordinator.data.group_client_list: @@ -781,8 +784,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): # The client is no longer part of the group. Prepare removal. client_ips_for_removal.append(expected_client_ip) - if len(client_ips_for_removal) > 0: - _LOGGER.info( + if client_ips_for_removal: + _LOGGER.debug( "%s says good bye to the following members %s", self.entity_id, str(client_ips_for_removal), @@ -795,3 +798,8 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): await self.async_server_close_group() self.async_write_ha_state() + + @callback + def async_schedule_check_client_list(self): + """Schedule async_check_client_list.""" + self.hass.create_task(self.async_check_client_list()) diff --git a/homeassistant/components/yamaha_musiccast/translations/de.json b/homeassistant/components/yamaha_musiccast/translations/de.json index 49e66419bdf..526e480602a 100644 --- a/homeassistant/components/yamaha_musiccast/translations/de.json +++ b/homeassistant/components/yamaha_musiccast/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "[%key::common::config_flow::abort::already_configured_device%]", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "yxc_control_url_missing": "Die Steuer-URL ist in der ssdp-Beschreibung nicht angegeben." }, "error": { @@ -10,11 +10,11 @@ "flow_title": "MusicCast: {name}", "step": { "confirm": { - "description": "[%key::common::config_flow::description::confirm_setup%]" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" }, "user": { "data": { - "host": "[%key::common::config_flow::data::host%]" + "host": "Host" }, "description": "Einrichten von MusicCast zur Integration mit Home Assistant." } diff --git a/homeassistant/components/yamaha_musiccast/translations/fr.json b/homeassistant/components/yamaha_musiccast/translations/fr.json new file mode 100644 index 00000000000..0a8671dc2aa --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "yxc_control_url_missing": "L'URL de contr\u00f4le n'est pas donn\u00e9e dans la description ssdp." + }, + "error": { + "no_musiccast_device": "Cet appareil ne semble pas \u00eatre un appareil MusicCast." + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "Voulez-vous commencer la configuration\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Configurer MusicCast pour l'int\u00e9grer \u00e0 Home Assistant." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yamaha_musiccast/translations/hu.json b/homeassistant/components/yamaha_musiccast/translations/hu.json index 0f973ce6dcc..9ddf75ca732 100644 --- a/homeassistant/components/yamaha_musiccast/translations/hu.json +++ b/homeassistant/components/yamaha_musiccast/translations/hu.json @@ -1,8 +1,13 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "yxc_control_url_missing": "A vez\u00e9rl\u0151 URL nincs megadva az ssdp le\u00edr\u00e1sban." }, + "error": { + "no_musiccast_device": "\u00dagy t\u0171nik, hogy ez az eszk\u00f6z nem MusicCast eszk\u00f6z." + }, + "flow_title": "MusicCast: {name}", "step": { "confirm": { "description": "El szeretn\u00e9d kezdeni a be\u00e1ll\u00edt\u00e1st?" @@ -10,7 +15,8 @@ "user": { "data": { "host": "Hoszt" - } + }, + "description": "\u00c1ll\u00edtsa be a MusicCast-ot a Homeassistanttal val\u00f3 integr\u00e1ci\u00f3hoz." } } } diff --git a/homeassistant/components/yamaha_musiccast/translations/id.json b/homeassistant/components/yamaha_musiccast/translations/id.json new file mode 100644 index 00000000000..72a79af2041 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/id.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "flow_title": "MusicCast: {name}", + "step": { + "confirm": { + "description": "Ingin memulai penyiapan?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/ar.json b/homeassistant/components/yeelight/translations/ar.json new file mode 100644 index 00000000000..e4146138625 --- /dev/null +++ b/homeassistant/components/yeelight/translations/ar.json @@ -0,0 +1,20 @@ +{ + "config": { + "flow_title": "{model} {host}", + "step": { + "discovery_confirm": { + "description": "\u0647\u0644 \u062a\u0631\u064a\u062f \u0625\u0639\u062f\u0627\u062f {model} ( {host} )\u061f" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "use_music_mode": "\u062a\u0645\u0643\u064a\u0646 \u0648\u0636\u0639 \u0627\u0644\u0645\u0648\u0633\u064a\u0642\u0649" + }, + "description": "\u0625\u0630\u0627 \u062a\u0631\u0643\u062a \u0627\u0644\u0646\u0645\u0648\u0630\u062c \u0641\u0627\u0631\u063a\u064b\u0627 \u060c \u0641\u0633\u064a\u062a\u0645 \u0627\u0643\u062a\u0634\u0627\u0641\u0647 \u062a\u0644\u0642\u0627\u0626\u064a\u064b\u0627." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yeelight/translations/de.json b/homeassistant/components/yeelight/translations/de.json index 0c9e6d2a154..e0bf573f95e 100644 --- a/homeassistant/components/yeelight/translations/de.json +++ b/homeassistant/components/yeelight/translations/de.json @@ -10,7 +10,7 @@ "flow_title": "{model} {host}", "step": { "discovery_confirm": { - "description": "M\u00f6chten Sie {model} ({host}) einrichten?" + "description": "M\u00f6chtest du {model} ({host}) einrichten?" }, "pick_device": { "data": { @@ -35,7 +35,7 @@ "transition": "\u00dcbergangszeit (ms)", "use_music_mode": "Musik-Modus aktivieren" }, - "description": "Wenn Sie das Modell leer lassen, wird es automatisch erkannt." + "description": "Wenn du das Modell leer l\u00e4sst, wird es automatisch erkannt." } } } diff --git a/homeassistant/components/yeelight/translations/fr.json b/homeassistant/components/yeelight/translations/fr.json index be55fe57ee0..9682f0d9b6f 100644 --- a/homeassistant/components/yeelight/translations/fr.json +++ b/homeassistant/components/yeelight/translations/fr.json @@ -7,7 +7,11 @@ "error": { "cannot_connect": "\u00c9chec de connexion" }, + "flow_title": "{model} {host}", "step": { + "discovery_confirm": { + "description": "Voulez-vous configurer {model} ({host})\u00a0?" + }, "pick_device": { "data": { "device": "Appareil" diff --git a/homeassistant/components/yeelight/translations/hu.json b/homeassistant/components/yeelight/translations/hu.json index cdb0839dd5a..26dc6cb5ba1 100644 --- a/homeassistant/components/yeelight/translations/hu.json +++ b/homeassistant/components/yeelight/translations/hu.json @@ -9,6 +9,9 @@ }, "flow_title": "{model} {host}", "step": { + "discovery_confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani a {model} ( {host} ) szolg\u00e1ltat\u00e1st?" + }, "pick_device": { "data": { "device": "Eszk\u00f6z" diff --git a/homeassistant/components/yeelight/translations/id.json b/homeassistant/components/yeelight/translations/id.json index 0c81739095d..3b2f0273ae3 100644 --- a/homeassistant/components/yeelight/translations/id.json +++ b/homeassistant/components/yeelight/translations/id.json @@ -7,7 +7,11 @@ "error": { "cannot_connect": "Gagal terhubung" }, + "flow_title": "{model} {host}", "step": { + "discovery_confirm": { + "description": "Ingin menyiapkan {model} ({host})?" + }, "pick_device": { "data": { "device": "Perangkat" diff --git a/homeassistant/components/youless/__init__.py b/homeassistant/components/youless/__init__.py new file mode 100644 index 00000000000..83c8209f558 --- /dev/null +++ b/homeassistant/components/youless/__init__.py @@ -0,0 +1,58 @@ +"""The youless integration.""" +from datetime import timedelta +import logging +from urllib.error import URLError + +from youless_api import YoulessAPI + +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.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up youless from a config entry.""" + api = YoulessAPI(entry.data[CONF_HOST]) + + try: + await hass.async_add_executor_job(api.initialize) + except URLError as exception: + raise ConfigEntryNotReady from exception + + async def async_update_data(): + """Fetch data from the API.""" + await hass.async_add_executor_job(api.update) + return api + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="youless_gateway", + update_method=async_update_data, + update_interval=timedelta(seconds=2), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(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/youless/config_flow.py b/homeassistant/components/youless/config_flow.py new file mode 100644 index 00000000000..2cf79ae64e0 --- /dev/null +++ b/homeassistant/components/youless/config_flow.py @@ -0,0 +1,50 @@ +"""Config flow for youless integration.""" +from __future__ import annotations + +import logging +from typing import Any +from urllib.error import HTTPError, URLError + +import voluptuous as vol +from youless_api import YoulessAPI + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_DEVICE, CONF_HOST +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +class YoulessConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for youless.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + api = YoulessAPI(user_input[CONF_HOST]) + await self.hass.async_add_executor_job(api.initialize) + except (HTTPError, URLError): + _LOGGER.exception("Cannot connect to host") + errors["base"] = "cannot_connect" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data={ + CONF_HOST: user_input[CONF_HOST], + CONF_DEVICE: api.mac_address, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/youless/const.py b/homeassistant/components/youless/const.py new file mode 100644 index 00000000000..adbfc521363 --- /dev/null +++ b/homeassistant/components/youless/const.py @@ -0,0 +1,3 @@ +"""Constants for the youless integration.""" + +DOMAIN = "youless" diff --git a/homeassistant/components/youless/manifest.json b/homeassistant/components/youless/manifest.json new file mode 100644 index 00000000000..d00f0457b85 --- /dev/null +++ b/homeassistant/components/youless/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "youless", + "name": "YouLess", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/youless", + "requirements": ["youless-api==0.10"], + "codeowners": ["@gjong"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py new file mode 100644 index 00000000000..54155034919 --- /dev/null +++ b/homeassistant/components/youless/sensor.py @@ -0,0 +1,197 @@ +"""The sensor entity for the Youless integration.""" +from __future__ import annotations + +from youless_api.youless_sensor import YoulessSensor + +from homeassistant.components.youless import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, DEVICE_CLASS_POWER +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Initialize the integration.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + device = entry.data[CONF_DEVICE] + if device is None: + device = entry.entry_id + + async_add_entities( + [ + GasSensor(coordinator, device), + PowerMeterSensor(coordinator, device, "low"), + PowerMeterSensor(coordinator, device, "high"), + PowerMeterSensor(coordinator, device, "total"), + CurrentPowerSensor(coordinator, device), + DeliveryMeterSensor(coordinator, device, "low"), + DeliveryMeterSensor(coordinator, device, "high"), + ExtraMeterSensor(coordinator, device, "total"), + ExtraMeterSensor(coordinator, device, "usage"), + ] + ) + + +class YoulessBaseSensor(CoordinatorEntity, Entity): + """The base sensor for Youless.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + device: str, + device_group: str, + friendly_name: str, + sensor_id: str, + ) -> None: + """Create the sensor.""" + super().__init__(coordinator) + self._device = device + self._device_group = device_group + self._sensor_id = sensor_id + + self._attr_unique_id = f"{DOMAIN}_{device}_{sensor_id}" + self._attr_device_info = { + "identifiers": {(DOMAIN, f"{device}_{device_group}")}, + "name": friendly_name, + "manufacturer": "YouLess", + "model": self.coordinator.data.model, + } + + @property + def get_sensor(self) -> YoulessSensor | None: + """Property to get the underlying sensor object.""" + return None + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement for the sensor.""" + if self.get_sensor is None: + return None + + return self.get_sensor.unit_of_measurement + + @property + def state(self) -> StateType: + """Determine the state value, only if a sensor is initialized.""" + if self.get_sensor is None: + return None + + return self.get_sensor.value + + @property + def available(self) -> bool: + """Return a flag to indicate the sensor not being available.""" + return super().available and self.get_sensor is not None + + +class GasSensor(YoulessBaseSensor): + """The Youless gas sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: + """Instantiate a gas sensor.""" + super().__init__(coordinator, device, "gas", "Gas meter", "gas") + self._attr_name = "Gas usage" + self._attr_icon = "mdi:fire" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + return self.coordinator.data.gas_meter + + +class CurrentPowerSensor(YoulessBaseSensor): + """The current power usage sensor.""" + + _attr_device_class = DEVICE_CLASS_POWER + + def __init__(self, coordinator: DataUpdateCoordinator, device: str) -> None: + """Instantiate the usage meter.""" + super().__init__(coordinator, device, "power", "Power usage", "usage") + self._device = device + self._attr_name = "Power Usage" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + return self.coordinator.data.current_power_usage + + +class DeliveryMeterSensor(YoulessBaseSensor): + """The Youless delivery meter value sensor.""" + + _attr_device_class = DEVICE_CLASS_POWER + + def __init__( + self, coordinator: DataUpdateCoordinator, device: str, dev_type: str + ) -> None: + """Instantiate a delivery meter sensor.""" + super().__init__( + coordinator, device, "delivery", "Power delivery", f"delivery_{dev_type}" + ) + self._type = dev_type + self._attr_name = f"Power delivery {dev_type}" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + if self.coordinator.data.delivery_meter is None: + return None + + return getattr(self.coordinator.data.delivery_meter, f"_{self._type}", None) + + +class PowerMeterSensor(YoulessBaseSensor): + """The Youless low meter value sensor.""" + + _attr_device_class = DEVICE_CLASS_POWER + + def __init__( + self, coordinator: DataUpdateCoordinator, device: str, dev_type: str + ) -> None: + """Instantiate a power meter sensor.""" + super().__init__( + coordinator, device, "power", "Power usage", f"power_{dev_type}" + ) + self._device = device + self._type = dev_type + self._attr_name = f"Power {dev_type}" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + if self.coordinator.data.power_meter is None: + return None + + return getattr(self.coordinator.data.power_meter, f"_{self._type}", None) + + +class ExtraMeterSensor(YoulessBaseSensor): + """The Youless extra meter value sensor (s0).""" + + _attr_device_class = DEVICE_CLASS_POWER + + def __init__( + self, coordinator: DataUpdateCoordinator, device: str, dev_type: str + ) -> None: + """Instantiate an extra meter sensor.""" + super().__init__( + coordinator, device, "extra", "Extra meter", f"extra_{dev_type}" + ) + self._type = dev_type + self._attr_name = f"Extra {dev_type}" + + @property + def get_sensor(self) -> YoulessSensor | None: + """Get the sensor for providing the value.""" + if self.coordinator.data.extra_meter is None: + return None + + return getattr(self.coordinator.data.extra_meter, f"_{self._type}", None) diff --git a/homeassistant/components/youless/strings.json b/homeassistant/components/youless/strings.json new file mode 100644 index 00000000000..3728db7ffe6 --- /dev/null +++ b/homeassistant/components/youless/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/ca.json b/homeassistant/components/youless/translations/ca.json new file mode 100644 index 00000000000..1237597b797 --- /dev/null +++ b/homeassistant/components/youless/translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/cs.json b/homeassistant/components/youless/translations/cs.json new file mode 100644 index 00000000000..7a27355056b --- /dev/null +++ b/homeassistant/components/youless/translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nepoda\u0159ilo se p\u0159ipojit" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "name": "Jm\u00e9no" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/de.json b/homeassistant/components/youless/translations/de.json new file mode 100644 index 00000000000..a87bbe1aa46 --- /dev/null +++ b/homeassistant/components/youless/translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/en.json b/homeassistant/components/youless/translations/en.json new file mode 100644 index 00000000000..584a8283675 --- /dev/null +++ b/homeassistant/components/youless/translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/et.json b/homeassistant/components/youless/translations/et.json new file mode 100644 index 00000000000..9a26513c333 --- /dev/null +++ b/homeassistant/components/youless/translations/et.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/fr.json b/homeassistant/components/youless/translations/fr.json new file mode 100644 index 00000000000..6f9c76d9ba1 --- /dev/null +++ b/homeassistant/components/youless/translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "name": "Nom" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/he.json b/homeassistant/components/youless/translations/he.json new file mode 100644 index 00000000000..33660936e12 --- /dev/null +++ b/homeassistant/components/youless/translations/he.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "name": "\u05e9\u05dd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/it.json b/homeassistant/components/youless/translations/it.json new file mode 100644 index 00000000000..8f93107a15d --- /dev/null +++ b/homeassistant/components/youless/translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Nome" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/nl.json b/homeassistant/components/youless/translations/nl.json new file mode 100644 index 00000000000..a05a1e161cc --- /dev/null +++ b/homeassistant/components/youless/translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan geen verbinding maken" + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Naam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/no.json b/homeassistant/components/youless/translations/no.json new file mode 100644 index 00000000000..01ea5b65fb1 --- /dev/null +++ b/homeassistant/components/youless/translations/no.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Navn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/pl.json b/homeassistant/components/youless/translations/pl.json new file mode 100644 index 00000000000..98acbf5ef4b --- /dev/null +++ b/homeassistant/components/youless/translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "name": "Nazwa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/ru.json b/homeassistant/components/youless/translations/ru.json new file mode 100644 index 00000000000..341b6a603aa --- /dev/null +++ b/homeassistant/components/youless/translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f." + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/youless/translations/zh-Hant.json b/homeassistant/components/youless/translations/zh-Hant.json new file mode 100644 index 00000000000..9ba777cefba --- /dev/null +++ b/homeassistant/components/youless/translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "name": "\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 9731e033972..a6018de831e 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, + DEVICE_CLASS_TEMPERATURE, LENGTH_METERS, PERCENTAGE, PRESSURE_HPA, @@ -43,43 +44,65 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) VIENNA_TIME_ZONE = dt_util.get_time_zone("Europe/Vienna") SENSOR_TYPES = { - "pressure": ("Pressure", PRESSURE_HPA, "LDstat hPa", float), - "pressure_sealevel": ("Pressure at Sea Level", PRESSURE_HPA, "LDred hPa", float), - "humidity": ("Humidity", PERCENTAGE, "RF %", int), + "pressure": ("Pressure", PRESSURE_HPA, None, "LDstat hPa", float), + "pressure_sealevel": ( + "Pressure at Sea Level", + PRESSURE_HPA, + None, + "LDred hPa", + float, + ), + "humidity": ("Humidity", PERCENTAGE, None, "RF %", int), "wind_speed": ( "Wind Speed", SPEED_KILOMETERS_PER_HOUR, + None, f"WG {SPEED_KILOMETERS_PER_HOUR}", float, ), - "wind_bearing": ("Wind Bearing", DEGREE, f"WR {DEGREE}", int), + "wind_bearing": ("Wind Bearing", DEGREE, None, f"WR {DEGREE}", int), "wind_max_speed": ( "Top Wind Speed", + None, SPEED_KILOMETERS_PER_HOUR, f"WSG {SPEED_KILOMETERS_PER_HOUR}", float, ), - "wind_max_bearing": ("Top Wind Bearing", DEGREE, f"WSR {DEGREE}", int), - "sun_last_hour": ("Sun Last Hour", PERCENTAGE, f"SO {PERCENTAGE}", int), - "temperature": ("Temperature", TEMP_CELSIUS, f"T {TEMP_CELSIUS}", float), + "wind_max_bearing": ("Top Wind Bearing", DEGREE, None, f"WSR {DEGREE}", int), + "sun_last_hour": ("Sun Last Hour", PERCENTAGE, None, f"SO {PERCENTAGE}", int), + "temperature": ( + "Temperature", + TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, + f"T {TEMP_CELSIUS}", + float, + ), "precipitation": ( "Precipitation", + None, f"l/{AREA_SQUARE_METERS}", f"N l/{AREA_SQUARE_METERS}", float, ), - "dewpoint": ("Dew Point", TEMP_CELSIUS, f"TP {TEMP_CELSIUS}", float), + "dewpoint": ( + "Dew Point", + TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, + f"TP {TEMP_CELSIUS}", + float, + ), # The following probably not useful for general consumption, # but we need them to fill in internal attributes - "station_name": ("Station Name", None, "Name", str), + "station_name": ("Station Name", None, None, "Name", str), "station_elevation": ( "Station Elevation", LENGTH_METERS, + None, f"Höhe {LENGTH_METERS}", int, ), - "update_date": ("Update Date", None, "Datum", str), - "update_time": ("Update Time", None, "Zeit", str), + "update_date": ("Update Date", None, None, "Datum", str), + "update_time": ("Update Time", None, None, "Zeit", str), } PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -140,6 +163,7 @@ class ZamgSensor(SensorEntity): self.probe = probe self.client_name = name self.variable = variable + self._attr_device_class = SENSOR_TYPES[variable][2] @property def name(self): @@ -217,6 +241,7 @@ class ZamgData: api_fields = { col_heading: (standard_name, dtype) for standard_name, ( + _, _, _, col_heading, @@ -258,7 +283,7 @@ def _get_zamg_stations(): try: stations[row["synnr"]] = tuple( float(row[coord].replace(",", ".")) - for coord in ["breite_dezi", "länge_dezi"] + for coord in ("breite_dezi", "länge_dezi") ) except KeyError: _LOGGER.error("ZAMG schema changed again, cannot autodetect station") diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index d8d664b63c5..4c4c81aff32 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,16 +11,12 @@ import socket from typing import Any, TypedDict, cast import voluptuous as vol -from zeroconf import ( - InterfaceChoice, - IPVersion, - NonUniqueNameException, - ServiceStateChange, -) +from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo -from homeassistant import config_entries, util +from homeassistant import config_entries from homeassistant.components import network +from homeassistant.components.network import async_get_source_ip from homeassistant.components.network.models import Adapter from homeassistant.const import ( EVENT_HOMEASSISTANT_START, @@ -154,14 +150,21 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: if not adapter["enabled"]: continue if ipv4s := adapter["ipv4"]: - interfaces.append(ipv4s[0]["address"]) - elif ipv6s := adapter["ipv6"]: - interfaces.append(ipv6s[0]["scope_id"]) + interfaces.extend( + ipv4["address"] + for ipv4 in ipv4s + if not ipaddress.ip_address(ipv4["address"]).is_loopback + ) + if adapter["ipv6"]: + ifi = socket.if_nametoindex(adapter["name"]) + interfaces.append(ifi) ipv6 = True if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): ipv6 = False zc_args["ip_version"] = IPVersion.V4Only + else: + zc_args["ip_version"] = IPVersion.All aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) @@ -194,6 +197,32 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: return True +def _get_announced_addresses( + adapters: list[Adapter], + first_ip: bytes | None = None, +) -> list[bytes]: + """Return a list of IP addresses to announce via zeroconf. + + If first_ip is not None, it will be the first address in the list. + """ + addresses = { + addr.packed + for addr in [ + ipaddress.ip_address(ip["address"]) + for adapter in adapters + if adapter["enabled"] + for ip in cast(list, adapter["ipv6"]) + cast(list, adapter["ipv4"]) + ] + if not (addr.is_unspecified or addr.is_loopback) + } + if first_ip: + address_list = [first_ip] + address_list.extend(addresses - set({first_ip})) + else: + address_list = list(addresses) + return address_list + + async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: @@ -222,12 +251,15 @@ async def _async_register_hass_zc_service( # Set old base URL based on external or internal params["base_url"] = params["external_url"] or params["internal_url"] - host_ip = util.get_local_ip() + adapters = await network.async_get_adapters(hass) - try: + # Puts the default IPv4 address first in the list to preserve compatibility, + # because some mDNS implementations ignores anything but the first announced address. + host_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) + host_ip_pton = None + if host_ip: host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) - except OSError: - host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip) + address_list = _get_announced_addresses(adapters, host_ip_pton) _suppress_invalid_properties(params) @@ -235,18 +267,13 @@ async def _async_register_hass_zc_service( ZEROCONF_TYPE, name=f"{valid_location_name}.{ZEROCONF_TYPE}", server=f"{uuid}.local.", - addresses=[host_ip_pton], + addresses=address_list, port=hass.http.server_port, properties=params, ) _LOGGER.info("Starting Zeroconf broadcast") - try: - await aio_zc.async_register_service(info) - except NonUniqueNameException: - _LOGGER.error( - "Home Assistant instance with identical name present in the local network" - ) + await aio_zc.async_register_service(info, allow_name_change=True) class FlowDispatcher: diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 199275623dc..ee1e9a8e1ab 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.32.1"], + "requirements": ["zeroconf==0.33.2"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/components/zerproc/translations/de.json b/homeassistant/components/zerproc/translations/de.json index dfc337fc844..19cd4b8c70e 100644 --- a/homeassistant/components/zerproc/translations/de.json +++ b/homeassistant/components/zerproc/translations/de.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "M\u00f6chtest du die Installation starten?" + "description": "M\u00f6chtest Du mit der Einrichtung beginnen?" } } } diff --git a/homeassistant/components/zerproc/translations/he.json b/homeassistant/components/zerproc/translations/he.json index e228b3719d1..459053197a6 100644 --- a/homeassistant/components/zerproc/translations/he.json +++ b/homeassistant/components/zerproc/translations/he.json @@ -6,7 +6,7 @@ }, "step": { "confirm": { - "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8?" + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05ea\u05d7\u05d9\u05dc \u05d1\u05d4\u05d2\u05d3\u05e8\u05d4?" } } } diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 801dedae0b6..e5b8c0936fd 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -145,10 +145,10 @@ async def async_unload_entry(hass, config_entry): # 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 - ] + ) ) hass.data[DATA_ZHA][DATA_ZHA_SHUTDOWN_TASK]() diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index e427bc962b4..c63d069767d 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -1,5 +1,5 @@ """Closures channels module for Zigbee Home Automation.""" -import zigpy.zcl.clusters.closures as closures +from zigpy.zcl.clusters import closures from homeassistant.core import callback diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index a0c2c3bc699..5ca8c9fd4ba 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -6,7 +6,7 @@ from collections.abc import Coroutine from typing import Any import zigpy.exceptions -import zigpy.zcl.clusters.general as general +from zigpy.zcl.clusters import general from zigpy.zcl.foundation import Status from homeassistant.core import callback diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 6e9d4138621..583cfb105bd 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Coroutine -import zigpy.zcl.clusters.homeautomation as homeautomation +from zigpy.zcl.clusters import homeautomation from .. import registries from ..const import ( diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 76f9c0b4e80..6b0cd9e5e28 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -11,7 +11,7 @@ from collections import namedtuple from typing import Any from zigpy.exceptions import ZigbeeException -import zigpy.zcl.clusters.hvac as hvac +from zigpy.zcl.clusters import hvac from zigpy.zcl.foundation import Status from homeassistant.core import callback diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 8c2b2bddd67..fbf53bec9a5 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine from contextlib import suppress -import zigpy.zcl.clusters.lighting as lighting +from zigpy.zcl.clusters import lighting from .. import registries from ..const import REPORT_CONFIG_DEFAULT diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py index e42a3b20053..46c40fdaff0 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/channels/lightlink.py @@ -2,7 +2,7 @@ import asyncio import zigpy.exceptions -import zigpy.zcl.clusters.lightlink as lightlink +from zigpy.zcl.clusters import lightlink from .. import registries from .base import ChannelStatus, ZigbeeChannel diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 99d062d4c3e..19ecc8a6335 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -1,5 +1,5 @@ """Measurement channels module for Zigbee Home Automation.""" -import zigpy.zcl.clusters.measurement as measurement +from zigpy.zcl.clusters import measurement from .. import registries from ..const import ( @@ -102,3 +102,17 @@ class CarbonDioxideConcentration(ZigbeeChannel): "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), } ] + + +@registries.ZIGBEE_CHANNEL_REGISTRY.register( + measurement.FormaldehydeConcentration.cluster_id +) +class FormaldehydeConcentration(ZigbeeChannel): + """Formaldehyde measurement channel.""" + + REPORT_CONFIG = [ + { + "attr": "measured_value", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + } + ] diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py index 4162809da7a..51d837a8014 100644 --- a/homeassistant/components/zha/core/channels/protocol.py +++ b/homeassistant/components/zha/core/channels/protocol.py @@ -1,5 +1,5 @@ """Protocol channels module for Zigbee Home Automation.""" -import zigpy.zcl.clusters.protocol as protocol +from zigpy.zcl.clusters import protocol from .. import registries from .base import ZigbeeChannel diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 2af44bdf4e1..cb90c740065 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -11,7 +11,7 @@ from collections.abc import Coroutine import logging from zigpy.exceptions import ZigbeeException -import zigpy.zcl.clusters.security as security +from zigpy.zcl.clusters import security from zigpy.zcl.clusters.security import IasAce as AceCluster from homeassistant.core import CALLABLE_T, callback diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index cfe395773ad..4e6302d32b5 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Coroutine -import zigpy.zcl.clusters.smartenergy as smartenergy +from zigpy.zcl.clusters import smartenergy from homeassistant.const import ( POWER_WATT, diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index c6166419e39..9e8a8450ec1 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -503,7 +503,7 @@ class ZHADevice(LogMixin): names.append( { ATTR_NAME: f"unknown {endpoint.device_type} device_type " - "of 0x{endpoint.profile_id:04x} profile id" + f"of 0x{endpoint.profile_id:04x} profile id" } ) device_info[ATTR_ENDPOINT_NAMES] = names diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 491f1a29774..d093c02d568 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -217,20 +217,20 @@ class ZHAGateway: _LOGGER.debug("Loading battery powered devices") await asyncio.gather( - *[ + *( _throttle(dev, cached=True) for dev in self.devices.values() if not dev.is_mains_powered - ] + ) ) _LOGGER.debug("Loading mains powered devices") await asyncio.gather( - *[ + *( _throttle(dev, cached=False) for dev in self.devices.values() if dev.is_mains_powered - ] + ) ) def device_joined(self, device): diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 5fe7f806355..04e97f8b7ed 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -5,9 +5,9 @@ import collections from typing import Callable, Dict import attr +from zigpy import zcl import zigpy.profiles.zha import zigpy.profiles.zll -import zigpy.zcl as zcl from homeassistant.components.alarm_control_panel import DOMAIN as ALARM from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR @@ -32,6 +32,7 @@ PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 SMARTTHINGS_HUMIDITY_CLUSTER = 0xFC45 +VOC_LEVEL_CLUSTER = 0x042E REMOTE_DEVICE_TYPES = { zigpy.profiles.zha.PROFILE_ID: [ @@ -62,6 +63,7 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { # a different dict that is keyed by manufacturer SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR, + VOC_LEVEL_CLUSTER: SENSOR, zcl.clusters.closures.DoorLock.cluster_id: LOCK, zcl.clusters.closures.WindowCovering.cluster_id: COVER, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, @@ -73,6 +75,7 @@ SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { zcl.clusters.hvac.Fan.cluster_id: FAN, zcl.clusters.measurement.CarbonDioxideConcentration.cluster_id: SENSOR, zcl.clusters.measurement.CarbonMonoxideConcentration.cluster_id: SENSOR, + zcl.clusters.measurement.FormaldehydeConcentration.cluster_id: SENSOR, zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: SENSOR, zcl.clusters.measurement.OccupancySensing.cluster_id: BINARY_SENSOR, zcl.clusters.measurement.PressureMeasurement.cluster_id: SENSOR, diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 0fe46a4628e..15e8be0db1e 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -26,8 +26,8 @@ ZigpyGroupType = zigpy.group.Group ZigpyZdoType = zigpy.zdo.ZDO if TYPE_CHECKING: + from homeassistant.components.zha.core import channels import homeassistant.components.zha.core.channels - import homeassistant.components.zha.core.channels as channels import homeassistant.components.zha.core.channels.base as base_channels import homeassistant.components.zha.core.device import homeassistant.components.zha.core.gateway diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 9d419b16435..de39ff50511 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -67,8 +67,8 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> list[dict]: ] actions = [ action - for channel in DEVICE_ACTIONS - for action in DEVICE_ACTIONS[channel] + for channel, channel_actions in DEVICE_ACTIONS.items() + for action in channel_actions if channel in cluster_channels ] for action in actions: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 3c50261b565..0176cf1ea3b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -6,7 +6,7 @@ import functools import math from zigpy.exceptions import ZigbeeException -import zigpy.zcl.clusters.hvac as hvac +from zigpy.zcl.clusters import hvac from homeassistant.components.fan import ( ATTR_PERCENTAGE, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index c7001611aa0..628d9c3b9be 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -167,6 +167,7 @@ class BaseLight(LogMixin, light.LightEntity): """Return the warmest color_temp that this light supports.""" return self._max_mireds + @callback def set_level(self, value): """Set the brightness of this light between 0..254. @@ -419,7 +420,7 @@ class Light(BaseLight, ZhaEntity): self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level ) - refresh_interval = random.randint(*[x * 60 for x in self._REFRESH_INTERVAL]) + refresh_interval = random.randint(*(x * 60 for x in self._REFRESH_INTERVAL)) self._cancel_refresh_handle = async_track_time_interval( self.hass, self._refresh, timedelta(seconds=refresh_interval) ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 081941d94fe..fa117a3f1ff 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,16 +4,16 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.25.0", + "bellows==0.26.0", "pyserial==3.5", "pyserial-asyncio==0.5", "zha-quirks==0.0.59", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", - "zigpy==0.35.2", + "zigpy==0.36.1", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", - "zigpy-znp==0.5.1" + "zigpy-znp==0.5.2" ], "codeowners": ["@dmulcahey", "@adminiuga"], "zeroconf": [ diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 714df80eebb..3c3aba919ed 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -20,6 +20,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -213,6 +214,7 @@ class ElectricalMeasurement(Sensor): SENSOR_ATTR = "active_power" _device_class = DEVICE_CLASS_POWER + _state_class = STATE_CLASS_MEASUREMENT _unit = POWER_WATT @property @@ -319,3 +321,24 @@ class CarbonMonoxideConcentration(Sensor): _decimals = 0 _multiplier = 1e6 _unit = CONCENTRATION_PARTS_PER_MILLION + + +@STRICT_MATCH(generic_ids="channel_0x042e") +@STRICT_MATCH(channel_names="voc_level") +class VOCLevel(Sensor): + """VOC Level sensor.""" + + SENSOR_ATTR = "measured_value" + _decimals = 0 + _multiplier = 1e6 + _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + +@STRICT_MATCH(channel_names="formaldehyde_concentration") +class FormaldehydeConcentration(Sensor): + """Formaldehyde Concentration sensor.""" + + SENSOR_ATTR = "measured_value" + _decimals = 0 + _multiplier = 1e6 + _unit = CONCENTRATION_PARTS_PER_MILLION diff --git a/homeassistant/components/zha/translations/ar.json b/homeassistant/components/zha/translations/ar.json new file mode 100644 index 00000000000..cdc300391e4 --- /dev/null +++ b/homeassistant/components/zha/translations/ar.json @@ -0,0 +1,10 @@ +{ + "config_panel": { + "zha_alarm_options": { + "title": "\u062e\u064a\u0627\u0631\u0627\u062a \u0644\u0648\u062d\u0629 \u0627\u0644\u062a\u062d\u0643\u0645 \u0641\u064a \u0627\u0644\u0625\u0646\u0630\u0627\u0631" + }, + "zha_options": { + "default_light_transition": "\u0648\u0642\u062a \u0627\u0646\u062a\u0642\u0627\u0644 \u0627\u0644\u0636\u0648\u0621 \u0627\u0644\u0627\u0641\u062a\u0631\u0627\u0636\u064a (\u0628\u0627\u0644\u062b\u0648\u0627\u0646\u064a)" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/translations/de.json b/homeassistant/components/zha/translations/de.json index 48a84e712f2..3d66cc63071 100644 --- a/homeassistant/components/zha/translations/de.json +++ b/homeassistant/components/zha/translations/de.json @@ -12,7 +12,7 @@ "data": { "radio_type": "Funktyp" }, - "description": "W\u00e4hlen Sie einen Typ Ihres Zigbee-Funks", + "description": "W\u00e4hle den Typ deines Zigbee-Funks", "title": "Funktyp" }, "port_config": { @@ -21,14 +21,14 @@ "flow_control": "Datenflusskontrolle", "path": "Serieller Ger\u00e4tepfad" }, - "description": "Geben Sie die portspezifischen Einstellungen ein", + "description": "Gib die portspezifischen Einstellungen ein", "title": "Einstellungen" }, "user": { "data": { "path": "Serieller Ger\u00e4tepfad" }, - "description": "W\u00e4hlen Sie die serielle Schnittstelle f\u00fcr den ZigBee-Funk", + "description": "W\u00e4hle die serielle Schnittstelle f\u00fcr den ZigBee-Funk", "title": "ZHA" } } @@ -44,7 +44,7 @@ "consider_unavailable_battery": "Batteriebetriebene Ger\u00e4te als nicht verf\u00fcgbar betrachten nach (Sekunden)", "consider_unavailable_mains": "Netzbetriebene Ger\u00e4te als nicht verf\u00fcgbar betrachten nach (Sekunden)", "default_light_transition": "Standardlicht\u00fcbergangszeit (Sekunden)", - "enable_identify_on_join": "Aktivieren Sie den Identifikationseffekt, wenn Ger\u00e4te dem Netzwerk beitreten", + "enable_identify_on_join": "Aktiviere den Identifikationseffekt, wenn Ger\u00e4te dem Netzwerk beitreten", "title": "Globale Optionen" } }, diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 75ba26ca809..90d0908d6c3 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -41,6 +41,8 @@ "title": "Options du panneau de contr\u00f4le d'alarme" }, "zha_options": { + "consider_unavailable_battery": "Consid\u00e9rer les appareils aliment\u00e9s par batterie indisponibles apr\u00e8s (secondes)", + "consider_unavailable_mains": "Consid\u00e9rer les appareils aliment\u00e9s par le secteur indisponibles apr\u00e8s (secondes)", "default_light_transition": "Temps de transition de la lumi\u00e8re par d\u00e9faut (en secondes)", "enable_identify_on_join": "Activer l'effet d'identification quand les appareils rejoignent le r\u00e9seau", "title": "Options g\u00e9n\u00e9rales" diff --git a/homeassistant/components/zha/translations/hu.json b/homeassistant/components/zha/translations/hu.json index 896d4fbad30..2b078092ed7 100644 --- a/homeassistant/components/zha/translations/hu.json +++ b/homeassistant/components/zha/translations/hu.json @@ -19,7 +19,25 @@ } } }, + "config_panel": { + "zha_alarm_options": { + "alarm_arm_requires_code": "Az \u00e9les\u00edt\u00e9si m\u0171veletekhez sz\u00fcks\u00e9ges k\u00f3d", + "alarm_failed_tries": "A riaszt\u00e1st kiv\u00e1lt\u00f3 egym\u00e1st k\u00f6vet\u0151 sikertelen k\u00f3dbejegyz\u00e9sek sz\u00e1ma", + "alarm_master_code": "A riaszt\u00f3 k\u00f6zpont(ok) mesterk\u00f3dja", + "title": "Riaszt\u00f3 vez\u00e9rl\u0151panel opci\u00f3k" + }, + "zha_options": { + "consider_unavailable_battery": "Fontolja meg, hogy az akkumul\u00e1toros eszk\u00f6z\u00f6k (m\u00e1sodpercek m\u00falva) nem \u00e9rhet\u0151k el", + "consider_unavailable_mains": "Fontolja meg, hogy a h\u00e1l\u00f3zati t\u00e1pell\u00e1t\u00e1s\u00fa eszk\u00f6z\u00f6k (m\u00e1sodpercek m\u00falva) nem \u00e9rhet\u0151k el", + "default_light_transition": "Alap\u00e9rtelmezett f\u00e9ny-\u00e1tmeneti id\u0151 (m\u00e1sodpercben)", + "enable_identify_on_join": "Azonos\u00edt\u00f3 hat\u00e1s enged\u00e9lyez\u00e9se, amikor az eszk\u00f6z\u00f6k csatlakoznak a h\u00e1l\u00f3zathoz", + "title": "Glob\u00e1lis be\u00e1ll\u00edt\u00e1sok" + } + }, "device_automation": { + "trigger_subtype": { + "turn_off": "Kikapcsol\u00e1s" + }, "trigger_type": { "device_offline": "Eszk\u00f6z offline" } diff --git a/homeassistant/components/zha/translations/id.json b/homeassistant/components/zha/translations/id.json index aaa563ffddf..4198352aae8 100644 --- a/homeassistant/components/zha/translations/id.json +++ b/homeassistant/components/zha/translations/id.json @@ -33,6 +33,20 @@ } } }, + "config_panel": { + "zha_alarm_options": { + "alarm_arm_requires_code": "Kode diperlukan untuk tindakan pengaktifkan alarm", + "alarm_failed_tries": "Jumlah entri kode yang gagal berturut-turut untuk memicu alarm", + "alarm_master_code": "Kode master untuk panel kontrol alarm", + "title": "Opsi Panel Kontrol Alarm" + }, + "zha_options": { + "consider_unavailable_battery": "Anggap perangkat bertenaga baterai sebagai tidak tersedia setelah (detik)", + "consider_unavailable_mains": "Anggap perangkat bertenaga listrik sebagai tidak tersedia setelah (detik)", + "enable_identify_on_join": "Aktifkan efek identifikasi saat perangkat bergabung dengan jaringan", + "title": "Opsi Global" + } + }, "device_automation": { "action_type": { "squawk": "Squawk", diff --git a/homeassistant/components/zha/translations/nl.json b/homeassistant/components/zha/translations/nl.json index f4cdb8f642b..9c63b8989cb 100644 --- a/homeassistant/components/zha/translations/nl.json +++ b/homeassistant/components/zha/translations/nl.json @@ -41,7 +41,7 @@ "title": "Alarm bedieningspaneel Opties" }, "zha_options": { - "consider_unavailable_battery": "Overweeg apparaten met batterijvoeding als onbeschikbaar na (seconden)", + "consider_unavailable_battery": "Beschouw apparaten met batterijvoeding als onbeschikbaar na (seconden)", "consider_unavailable_mains": "Beschouw apparaten op netvoeding als onbeschikbaar na (seconden)", "default_light_transition": "Standaard licht transitietijd (seconden)", "enable_identify_on_join": "Schakel het identificatie-effect in wanneer apparaten in het netwerk komen", diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json index 6f88ca16ae9..8644cdbc03b 100644 --- a/homeassistant/components/zha/translations/ru.json +++ b/homeassistant/components/zha/translations/ru.json @@ -41,8 +41,8 @@ "title": "\u041e\u043f\u0446\u0438\u0438 \u043f\u0430\u043d\u0435\u043b\u0438 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0435\u0439" }, "zha_options": { - "consider_unavailable_battery": "\u0412\u0440\u0435\u043c\u044f, \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 \u043f\u0438\u0442\u0430\u043d\u0438\u0435\u043c \u043e\u0442 \u0431\u0430\u0442\u0430\u0440\u0435\u0438 \u0431\u0443\u0434\u0435\u0442 \u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0441\u044f \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", - "consider_unavailable_mains": "\u0412\u0440\u0435\u043c\u044f, \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u0438 \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441 \u043f\u0438\u0442\u0430\u043d\u0438\u0435\u043c \u043e\u0442 \u0441\u0435\u0442\u0438 \u0431\u0443\u0434\u0435\u0442 \u0441\u0447\u0438\u0442\u0430\u0442\u044c\u0441\u044f \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", + "consider_unavailable_battery": "\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u0430\u0432\u0442\u043e\u043d\u043e\u043c\u043d\u044b\u043c \u043f\u0438\u0442\u0430\u043d\u0438\u0435\u043c \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c\u0438 \u0447\u0435\u0440\u0435\u0437 (\u0441\u0435\u043a\u0443\u043d\u0434)", + "consider_unavailable_mains": "\u0421\u0447\u0438\u0442\u0430\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 \u043f\u0438\u0442\u0430\u043d\u0438\u0435\u043c \u043e\u0442 \u0441\u0435\u0442\u0438 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u043c\u0438 \u0447\u0435\u0440\u0435\u0437 (\u0441\u0435\u043a\u0443\u043d\u0434)", "default_light_transition": "\u0412\u0440\u0435\u043c\u044f \u043f\u043b\u0430\u0432\u043d\u043e\u0433\u043e \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 \u0441\u0432\u0435\u0442\u0430 \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", "enable_identify_on_join": "\u042d\u0444\u0444\u0435\u043a\u0442 \u0434\u043b\u044f \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043f\u0440\u0438\u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043a \u0441\u0435\u0442\u0438", "title": "\u0413\u043b\u043e\u0431\u0430\u043b\u044c\u043d\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438" diff --git a/homeassistant/components/zone/translations/ar.json b/homeassistant/components/zone/translations/ar.json new file mode 100644 index 00000000000..7db7791b8d6 --- /dev/null +++ b/homeassistant/components/zone/translations/ar.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "\u0627\u0644\u0627\u0633\u0645 \u0645\u0648\u062c\u0648\u062f \u0628\u0627\u0644\u0641\u0639\u0644" + }, + "step": { + "init": { + "data": { + "icon": "\u0623\u064a\u0642\u0648\u0646\u0629", + "latitude": "\u062e\u0637 \u0627\u0644\u0639\u0631\u0636", + "longitude": "\u062e\u0637 \u0627\u0644\u0637\u0648\u0644", + "name": "\u0627\u0644\u0627\u0633\u0645", + "radius": "\u0646\u0635\u0641 \u0627\u0644\u0642\u0637\u0631" + }, + "title": "\u062a\u062d\u062f\u064a\u062f \u0645\u0639\u0644\u0645\u0627\u062a \u0627\u0644\u0645\u0646\u0637\u0642\u0629" + } + }, + "title": "\u0645\u0646\u0637\u0642\u0629" + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/translations/de.json b/homeassistant/components/zoneminder/translations/de.json index af053e59ec3..a0bf38b8def 100644 --- a/homeassistant/components/zoneminder/translations/de.json +++ b/homeassistant/components/zoneminder/translations/de.json @@ -23,7 +23,7 @@ "password": "Passwort", "path": "ZM-Pfad", "path_zms": "ZMS-Pfad", - "ssl": "Nutzt ein SSL-Zertifikat", + "ssl": "Verwendet ein SSL-Zertifikat", "username": "Benutzername", "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" }, diff --git a/homeassistant/components/zoneminder/translations/hu.json b/homeassistant/components/zoneminder/translations/hu.json index a40d9299251..a449464e27f 100644 --- a/homeassistant/components/zoneminder/translations/hu.json +++ b/homeassistant/components/zoneminder/translations/hu.json @@ -10,18 +10,24 @@ "default": "ZoneMinder szerver hozz\u00e1adva." }, "error": { + "auth_fail": "\u00c9rv\u00e9nytelen felhaszn\u00e1l\u00f3n\u00e9v vagy jelsz\u00f3", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "connection_error": "Nem siker\u00fclt csatlakozni a ZoneMinder szerverhez.", "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s" }, + "flow_title": "ZoneMinder", "step": { "user": { "data": { + "host": "Host \u00e9s Port (pl. 10.10.0.4:8010)", "password": "Jelsz\u00f3", + "path": "ZM \u00fatvonal", + "path_zms": "ZMS el\u00e9r\u00e9si \u00fat", "ssl": "SSL tan\u00fas\u00edtv\u00e1ny haszn\u00e1lata", "username": "Felhaszn\u00e1l\u00f3n\u00e9v", "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" - } + }, + "title": "Adja hozz\u00e1 a ZoneMinder szervert." } } } diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 6cf39709aaf..8a5705ae7bb 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -1114,8 +1114,8 @@ class ZWaveDeviceEntityValues: """ if not check_node_schema(value.node, self._schema): return - for name in self._values: - if self._values[name] is not None: + for name, name_value in self._values.items(): + if name_value is not None: continue if not check_value_schema(value, self._schema[const.DISC_VALUES][name]): continue diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index a3183ba8927..d973e52ff92 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -1,6 +1,6 @@ """Support for Z-Wave sensors.""" from homeassistant.components.sensor import DEVICE_CLASS_BATTERY, DOMAIN, SensorEntity -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -79,6 +79,13 @@ class ZWaveMultilevelSensor(ZWaveSensor): return self._state + @property + def device_class(self): + """Return the class of this device.""" + if self._units in ["C", "F"]: + return DEVICE_CLASS_TEMPERATURE + return None + @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" diff --git a/homeassistant/components/zwave/translations/de.json b/homeassistant/components/zwave/translations/de.json index 0a82d5b0bc7..9d432edd1e5 100644 --- a/homeassistant/components/zwave/translations/de.json +++ b/homeassistant/components/zwave/translations/de.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Z-Wave ist bereits konfiguriert", + "already_configured": "Ger\u00e4t ist bereits konfiguriert", "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." }, "error": { @@ -11,9 +11,9 @@ "user": { "data": { "network_key": "Netzwerkschl\u00fcssel (leer lassen, um automatisch zu generieren)", - "usb_path": "USB-Ger\u00e4t Pfad" + "usb_path": "USB-Ger\u00e4te-Pfad" }, - "description": "Diese Integration wird nicht mehr gepflegt. Verwenden Sie bei Neuinstallationen stattdessen Z-Wave JS.\n\nSiehe https://www.home-assistant.io/docs/z-wave/installation/ f\u00fcr Informationen zu den Konfigurationsvariablen" + "description": "Diese Integration wird nicht mehr gepflegt. Verwende bei Neuinstallationen stattdessen Z-Wave JS.\n\nSiehe https://www.home-assistant.io/docs/z-wave/installation/ f\u00fcr Informationen zu den Konfigurationsvariablen" } } }, @@ -26,7 +26,7 @@ }, "query_stage": { "dead": "Nicht erreichbar ({query_stage})", - "initializing": "Initialisiere" + "initializing": "Initialisierend" } } } \ No newline at end of file diff --git a/homeassistant/components/zwave/translations/he.json b/homeassistant/components/zwave/translations/he.json index 585b696b496..9cbf39a6d16 100644 --- a/homeassistant/components/zwave/translations/he.json +++ b/homeassistant/components/zwave/translations/he.json @@ -15,13 +15,13 @@ "state": { "_": { "dead": "\u05de\u05ea", - "initializing": "\u05de\u05d0\u05ea\u05d7\u05dc", + "initializing": "\u05d0\u05ea\u05d7\u05d5\u05dc", "ready": "\u05de\u05d5\u05db\u05df", "sleeping": "\u05d9\u05e9\u05df" }, "query_stage": { - "dead": "\u05de\u05ea ({query_stage})", - "initializing": "\u05de\u05d0\u05ea\u05d7\u05dc ({query_stage})" + "dead": "\u05de\u05ea", + "initializing": "\u05d0\u05ea\u05d7\u05d5\u05dc" } } } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index fee4da743c8..6cd0104e298 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections import defaultdict -from typing import Callable from async_timeout import timeout from zwave_js_server.client import Client as ZwaveClient @@ -61,7 +60,6 @@ from .const import ( CONF_USE_ADDON, DATA_CLIENT, DATA_PLATFORM_SETUP, - DATA_UNSUBSCRIBE, DOMAIN, EVENT_DEVICE_ADDED_TO_REGISTRY, LOGGER, @@ -126,9 +124,7 @@ async def async_setup_entry( # noqa: C901 ent_reg = entity_registry.async_get(hass) entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) - unsubscribe_callbacks: list[Callable] = [] entry_hass_data[DATA_CLIENT] = client - entry_hass_data[DATA_UNSUBSCRIBE] = unsubscribe_callbacks entry_hass_data[DATA_PLATFORM_SETUP] = {} registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict) @@ -145,7 +141,7 @@ async def async_setup_entry( # noqa: C901 if device.id not in registered_unique_ids: registered_unique_ids[device.id] = defaultdict(set) - value_updates_disc_info = [] + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities for disc_info in async_discover_values(node): @@ -177,11 +173,11 @@ async def async_setup_entry( # noqa: C901 # Capture discovery info for values we want to watch for updates if disc_info.assumed_state: - value_updates_disc_info.append(disc_info) + value_updates_disc_info[disc_info.primary_value.value_id] = disc_info # add listener for value updated events if necessary if value_updates_disc_info: - unsubscribe_callbacks.append( + entry.async_on_unload( node.on( "value updated", lambda event: async_on_value_updated( @@ -191,14 +187,14 @@ async def async_setup_entry( # noqa: C901 ) # add listener for stateless node value notification events - unsubscribe_callbacks.append( + entry.async_on_unload( node.on( "value notification", lambda event: async_on_value_notification(event["value_notification"]), ) ) # add listener for stateless node notification events - unsubscribe_callbacks.append( + entry.async_on_unload( node.on( "notification", lambda event: async_on_notification(event["notification"]), @@ -317,19 +313,14 @@ async def async_setup_entry( # noqa: C901 @callback def async_on_value_updated( - value_updates_disc_info: list[ZwaveDiscoveryInfo], value: Value + value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value ) -> None: """Fire value updated event.""" - # Get the discovery info for the value that was updated. If we can't - # find the discovery info, we don't need to fire an event - try: - disc_info = next( - disc_info - for disc_info in value_updates_disc_info - if disc_info.primary_value.value_id == value.value_id - ) - except StopIteration: + # Get the discovery info for the value that was updated. If there is + # no discovery info for this value, we don't need to fire an event + if value.value_id not in value_updates_disc_info: return + disc_info = value_updates_disc_info[value.value_id] device = dev_reg.async_get_device({get_device_id(client, value.node)}) @@ -400,7 +391,7 @@ async def async_setup_entry( # noqa: C901 client_listen(hass, entry, client, driver_ready) ) entry_hass_data[DATA_CLIENT_LISTEN_TASK] = listen_task - unsubscribe_callbacks.append( + entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown) ) @@ -435,14 +426,14 @@ async def async_setup_entry( # noqa: C901 # run discovery on all ready nodes await asyncio.gather( - *[ + *( async_on_node_added(node) for node in client.driver.controller.nodes.values() - ] + ) ) # listen for new nodes being added to the mesh - unsubscribe_callbacks.append( + entry.async_on_unload( client.driver.controller.on( "node added", lambda event: hass.async_create_task( @@ -452,7 +443,7 @@ async def async_setup_entry( # noqa: C901 ) # listen for nodes being removed from the mesh # NOTE: This will not remove nodes that were removed when HA was not running - unsubscribe_callbacks.append( + entry.async_on_unload( client.driver.controller.on( "node removed", lambda event: async_on_node_removed(event["node"]) ) @@ -515,9 +506,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" info = hass.data[DOMAIN][entry.entry_id] - for unsub in info[DATA_UNSUBSCRIBE]: - unsub() - tasks = [] for platform, task in info[DATA_PLATFORM_SETUP].items(): if task.done(): diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 5cf98d44802..a55ae47b935 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations import dataclasses from functools import partial, wraps import json -from typing import Callable +from typing import Any, Callable from aiohttp import hdrs, web, web_exceptions, web_request import voluptuous as vol @@ -13,18 +13,20 @@ from zwave_js_server.client import Client from zwave_js_server.const import CommandClass, LogLevel from zwave_js_server.exceptions import ( BaseZwaveJSServerError, + FailedCommand, InvalidNewValue, NotFoundError, SetValueFailed, ) from zwave_js_server.firmware import begin_firmware_update +from zwave_js_server.model.controller import ControllerStatistics from zwave_js_server.model.firmware import ( FirmwareUpdateFinished, FirmwareUpdateProgress, ) from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage -from zwave_js_server.model.node import Node +from zwave_js_server.model.node import Node, NodeStatistics from zwave_js_server.util.node import async_set_config_parameter from homeassistant.components import websocket_api @@ -53,6 +55,8 @@ from .const import ( from .helpers import async_enable_statistics, update_data_collection_preference from .services import BITMASK_SCHEMA +DATA_UNSUBSCRIBE = "unsubs" + # general API constants ID = "id" ENTRY_ID = "entry_id" @@ -134,6 +138,30 @@ def async_get_node(orig_func: Callable) -> Callable: return async_get_node_func +def async_handle_failed_command(orig_func: Callable) -> Callable: + """Decorate async function to handle FailedCommand and send relevant error.""" + + @wraps(orig_func) + async def async_handle_failed_command_func( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + *args: Any, + **kwargs: Any, + ) -> None: + """Handle FailedCommand within function and send relevant error.""" + try: + await orig_func(hass, connection, msg, *args, **kwargs) + except FailedCommand as err: + # Unsubscribe to callbacks + if unsubs := msg.get(DATA_UNSUBSCRIBE): + for unsub in unsubs: + unsub() + connection.send_error(msg[ID], err.error_code, err.args[0]) + + return async_handle_failed_command_func + + @callback def async_register_api(hass: HomeAssistant) -> None: """Register all of our api endpoints.""" @@ -173,6 +201,10 @@ def async_register_api(hass: HomeAssistant) -> None: ) websocket_api.async_register_command(hass, websocket_check_for_config_updates) websocket_api.async_register_command(hass, websocket_install_config_update) + websocket_api.async_register_command( + hass, websocket_subscribe_controller_statistics + ) + websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) hass.http.register_view(DumpView()) hass.http.register_view(FirmwareUploadView()) @@ -318,6 +350,7 @@ async def websocket_node_metadata( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_ping_node( hass: HomeAssistant, @@ -342,6 +375,7 @@ async def websocket_ping_node( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_add_node( hass: HomeAssistant, @@ -410,7 +444,7 @@ async def websocket_add_node( ) connection.subscriptions[msg["id"]] = async_cleanup - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), @@ -435,6 +469,7 @@ async def websocket_add_node( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_stop_inclusion( hass: HomeAssistant, @@ -460,6 +495,7 @@ async def websocket_stop_inclusion( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_stop_exclusion( hass: HomeAssistant, @@ -485,6 +521,7 @@ async def websocket_stop_exclusion( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_remove_node( hass: HomeAssistant, @@ -522,7 +559,7 @@ async def websocket_remove_node( ) connection.subscriptions[msg["id"]] = async_cleanup - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("exclusion started", forward_event), controller.on("exclusion failed", forward_event), controller.on("exclusion stopped", forward_event), @@ -546,6 +583,7 @@ async def websocket_remove_node( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_replace_failed_node( hass: HomeAssistant, @@ -628,7 +666,7 @@ async def websocket_replace_failed_node( ) connection.subscriptions[msg["id"]] = async_cleanup - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("inclusion started", forward_event), controller.on("inclusion failed", forward_event), controller.on("inclusion stopped", forward_event), @@ -655,6 +693,7 @@ async def websocket_replace_failed_node( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_remove_failed_node( hass: HomeAssistant, @@ -670,7 +709,8 @@ async def websocket_remove_failed_node( @callback def async_cleanup() -> None: """Remove signal listeners.""" - unsub() + for unsub in unsubs: + unsub() @callback def node_removed(event: dict) -> None: @@ -686,7 +726,7 @@ async def websocket_remove_failed_node( ) connection.subscriptions[msg["id"]] = async_cleanup - unsub = controller.on("node removed", node_removed) + msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)] result = await controller.async_remove_failed_node(node_id) connection.send_result( @@ -703,6 +743,7 @@ async def websocket_remove_failed_node( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_begin_healing_network( hass: HomeAssistant, @@ -755,12 +796,12 @@ async def websocket_subscribe_heal_network_progress( ) connection.subscriptions[msg["id"]] = async_cleanup - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ controller.on("heal network progress", partial(forward_event, "progress")), controller.on("heal network done", partial(forward_event, "result")), ] - connection.send_result(msg[ID]) + connection.send_result(msg[ID], controller.heal_network_progress) @websocket_api.require_admin @@ -771,6 +812,7 @@ async def websocket_subscribe_heal_network_progress( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_stop_healing_network( hass: HomeAssistant, @@ -797,6 +839,7 @@ async def websocket_stop_healing_network( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_heal_node( hass: HomeAssistant, @@ -824,6 +867,7 @@ async def websocket_heal_node( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_refresh_node_info( hass: HomeAssistant, @@ -854,7 +898,7 @@ async def websocket_refresh_node_info( ) connection.subscriptions[msg["id"]] = async_cleanup - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ node.on("interview started", forward_event), node.on("interview completed", forward_event), node.on("interview stage completed", forward_stage), @@ -874,6 +918,7 @@ async def websocket_refresh_node_info( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_refresh_node_values( hass: HomeAssistant, @@ -896,6 +941,7 @@ async def websocket_refresh_node_values( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_refresh_node_cc_values( hass: HomeAssistant, @@ -930,6 +976,7 @@ async def websocket_refresh_node_cc_values( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_set_config_parameter( hass: HomeAssistant, @@ -1027,6 +1074,7 @@ def filename_is_present_if_logging_to_file(obj: dict) -> dict: } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_subscribe_log_updates( hass: HomeAssistant, @@ -1076,7 +1124,7 @@ async def websocket_subscribe_log_updates( ) ) - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ driver.on("logging", log_messages), driver.on("log config updated", log_config_updates), ] @@ -1114,6 +1162,7 @@ async def websocket_subscribe_log_updates( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_update_log_config( hass: HomeAssistant, @@ -1161,6 +1210,7 @@ async def websocket_get_log_config( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_update_data_collection_preference( hass: HomeAssistant, @@ -1191,6 +1241,7 @@ async def websocket_update_data_collection_preference( }, ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_data_collection_status( hass: HomeAssistant, @@ -1273,6 +1324,7 @@ async def websocket_version_info( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_node async def websocket_abort_firmware_update( hass: HomeAssistant, @@ -1285,6 +1337,16 @@ async def websocket_abort_firmware_update( connection.send_result(msg[ID]) +def _get_firmware_update_progress_dict( + progress: FirmwareUpdateProgress, +) -> dict[str, int]: + """Get a dictionary of firmware update progress.""" + return { + "sent_fragments": progress.sent_fragments, + "total_fragments": progress.total_fragments, + } + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -1301,7 +1363,7 @@ async def websocket_subscribe_firmware_update_status( msg: dict, node: Node, ) -> None: - """Subsribe to the status of a firmware update.""" + """Subscribe to the status of a firmware update.""" @callback def async_cleanup() -> None: @@ -1317,8 +1379,7 @@ async def websocket_subscribe_firmware_update_status( msg[ID], { "event": event["event"], - "sent_fragments": progress.sent_fragments, - "total_fragments": progress.total_fragments, + **_get_firmware_update_progress_dict(progress), }, ) ) @@ -1337,13 +1398,16 @@ async def websocket_subscribe_firmware_update_status( ) ) - unsubs = [ + msg[DATA_UNSUBSCRIBE] = unsubs = [ node.on("firmware update progress", forward_progress), node.on("firmware update finished", forward_finished), ] connection.subscriptions[msg["id"]] = async_cleanup - connection.send_result(msg[ID]) + progress = node.firmware_update_progress + connection.send_result( + msg[ID], _get_firmware_update_progress_dict(progress) if progress else None + ) class FirmwareUploadView(HomeAssistantView): @@ -1400,6 +1464,7 @@ class FirmwareUploadView(HomeAssistantView): } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_check_for_config_updates( hass: HomeAssistant, @@ -1427,6 +1492,7 @@ async def websocket_check_for_config_updates( } ) @websocket_api.async_response +@async_handle_failed_command @async_get_entry async def websocket_install_config_update( hass: HomeAssistant, @@ -1438,3 +1504,126 @@ async def websocket_install_config_update( """Check for config updates.""" success = await client.driver.async_install_config_update() connection.send_result(msg[ID], success) + + +def _get_controller_statistics_dict( + statistics: ControllerStatistics, +) -> dict[str, int]: + """Get dictionary of controller statistics.""" + return { + "messages_tx": statistics.messages_tx, + "messages_rx": statistics.messages_rx, + "messages_dropped_tx": statistics.messages_dropped_tx, + "messages_dropped_rx": statistics.messages_dropped_rx, + "nak": statistics.nak, + "can": statistics.can, + "timeout_ack": statistics.timeout_ack, + "timout_response": statistics.timeout_response, + "timeout_callback": statistics.timeout_callback, + } + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_controller_statistics", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_get_entry +async def websocket_subscribe_controller_statistics( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, +) -> None: + """Subsribe to the statistics updates for a controller.""" + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_stats(event: dict) -> None: + statistics: ControllerStatistics = event["statistics_updated"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "source": "controller", + **_get_controller_statistics_dict(statistics), + }, + ) + ) + + controller = client.driver.controller + + msg[DATA_UNSUBSCRIBE] = unsubs = [ + controller.on("statistics updated", forward_stats) + ] + connection.subscriptions[msg["id"]] = async_cleanup + + connection.send_result( + msg[ID], _get_controller_statistics_dict(controller.statistics) + ) + + +def _get_node_statistics_dict(statistics: NodeStatistics) -> dict[str, int]: + """Get dictionary of node statistics.""" + return { + "commands_tx": statistics.commands_tx, + "commands_rx": statistics.commands_rx, + "commands_dropped_tx": statistics.commands_dropped_tx, + "commands_dropped_rx": statistics.commands_dropped_rx, + "timeout_response": statistics.timeout_response, + } + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/subscribe_node_statistics", + vol.Required(ENTRY_ID): str, + vol.Required(NODE_ID): int, + } +) +@websocket_api.async_response +@async_get_node +async def websocket_subscribe_node_statistics( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Subsribe to the statistics updates for a node.""" + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + + @callback + def forward_stats(event: dict) -> None: + statistics: NodeStatistics = event["statistics_updated"] + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": event["event"], + "source": "node", + "node_id": node.node_id, + **_get_node_statistics_dict(statistics), + }, + ) + ) + + msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("statistics updated", forward_stats)] + connection.subscriptions[msg["id"]] = async_cleanup + + connection.send_result(msg[ID], _get_node_statistics_dict(node.statistics)) diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 537f4f8e49e..9d72a804ca0 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -27,7 +27,7 @@ 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_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -249,7 +249,7 @@ async def async_setup_entry( async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{BINARY_SENSOR_DOMAIN}", @@ -277,12 +277,6 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): if self.info.primary_value.command_class == CommandClass.BATTERY else None ) - # Legacy binary sensors are phased out (replaced by notification sensors) - # Disable by default to not confuse users - self._attr_entity_registry_enabled_default = bool( - self.info.primary_value.command_class != CommandClass.SENSOR_BINARY - or self.info.node.device_class.generic.key == 0x20 - ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 43363538500..1621e87cfab 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -59,7 +59,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.temperature import convert_temperature -from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value @@ -121,7 +121,7 @@ async def async_setup_entry( async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{CLIMATE_DOMAIN}", diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 9e6e37b4ee7..7848af146b5 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -13,7 +13,6 @@ CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" DOMAIN = "zwave_js" DATA_CLIENT = "client" -DATA_UNSUBSCRIBE = "unsubs" DATA_PLATFORM_SETUP = "platform_setup" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" @@ -44,26 +43,30 @@ ATTR_EVENT_TYPE = "event_type" ATTR_EVENT_DATA = "event_data" ATTR_DATA_TYPE = "data_type" ATTR_WAIT_FOR_RESULT = "wait_for_result" +ATTR_OPTIONS = "options" + +ATTR_NODE = "node" +ATTR_ZWAVE_VALUE = "zwave_value" # service constants -ATTR_NODES = "nodes" - +SERVICE_SET_VALUE = "set_value" +SERVICE_RESET_METER = "reset_meter" +SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" +SERVICE_PING = "ping" +SERVICE_REFRESH_VALUE = "refresh_value" SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter" SERVICE_BULK_SET_PARTIAL_CONFIG_PARAMETERS = "bulk_set_partial_config_parameters" +ATTR_NODES = "nodes" +# config parameter ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_PARAMETER_BITMASK = "bitmask" ATTR_CONFIG_VALUE = "value" - -SERVICE_REFRESH_VALUE = "refresh_value" - +# refresh value ATTR_REFRESH_ALL_VALUES = "refresh_all_values" - -SERVICE_SET_VALUE = "set_value" -SERVICE_MULTICAST_SET_VALUE = "multicast_set_value" - +# multicast ATTR_BROADCAST = "broadcast" - -SERVICE_PING = "ping" +# meter reset +ATTR_METER_TYPE = "meter_type" ADDON_SLUG = "core_zwave_js" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index e01f2871604..f8e575521dc 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -5,6 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import BarrierState from zwave_js_server.model.value import Value as ZwaveValue from homeassistant.components.cover import ( @@ -23,21 +24,12 @@ 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_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) -BARRIER_TARGET_CLOSE = 0 -BARRIER_TARGET_OPEN = 255 - -BARRIER_STATE_CLOSED = 0 -BARRIER_STATE_CLOSING = 252 -BARRIER_STATE_STOPPED = 253 -BARRIER_STATE_OPENING = 254 -BARRIER_STATE_OPEN = 255 - async def async_setup_entry( hass: HomeAssistant, @@ -57,7 +49,7 @@ async def async_setup_entry( entities.append(ZWaveCover(config_entry, client, info)) async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{COVER_DOMAIN}", @@ -130,12 +122,23 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): async def async_stop_cover(self, **kwargs: Any) -> None: """Stop cover.""" - target_value = self.get_zwave_value("Open") or self.get_zwave_value("Up") - if target_value: - await self.info.node.async_set_value(target_value, False) - target_value = self.get_zwave_value("Close") or self.get_zwave_value("Down") - if target_value: - await self.info.node.async_set_value(target_value, False) + open_value = ( + self.get_zwave_value("Open") + or self.get_zwave_value("Up") + or self.get_zwave_value("On") + ) + if open_value: + # Stop the cover if it's opening + await self.info.node.async_set_value(open_value, False) + + close_value = ( + self.get_zwave_value("Close") + or self.get_zwave_value("Down") + or self.get_zwave_value("Off") + ) + if close_value: + # Stop the cover if it's closing + await self.info.node.async_set_value(close_value, False) class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): @@ -161,14 +164,14 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): """Return if the cover is opening or not.""" if self.info.primary_value.value is None: return None - return bool(self.info.primary_value.value == BARRIER_STATE_OPENING) + return bool(self.info.primary_value.value == BarrierState.OPENING) @property def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" if self.info.primary_value.value is None: return None - return bool(self.info.primary_value.value == BARRIER_STATE_CLOSING) + return bool(self.info.primary_value.value == BarrierState.CLOSING) @property def is_closed(self) -> bool | None: @@ -179,15 +182,15 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): # issuing an open cover command. Return None in this case which # produces an unknown state and allows it to be resolved with an open # command. - if self.info.primary_value.value == BARRIER_STATE_STOPPED: + if self.info.primary_value.value == BarrierState.STOPPED: return None - return bool(self.info.primary_value.value == BARRIER_STATE_CLOSED) + return bool(self.info.primary_value.value == BarrierState.CLOSED) async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" - await self.info.node.async_set_value(self._target_state, BARRIER_TARGET_OPEN) + await self.info.node.async_set_value(self._target_state, BarrierState.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" - await self.info.node.async_set_value(self._target_state, BARRIER_TARGET_CLOSE) + await self.info.node.async_set_value(self._target_state, BarrierState.CLOSED) diff --git a/homeassistant/components/zwave_js/device_condition.py b/homeassistant/components/zwave_js/device_condition.py new file mode 100644 index 00000000000..b419230a0bd --- /dev/null +++ b/homeassistant/components/zwave_js/device_condition.py @@ -0,0 +1,217 @@ +"""Provide the device conditions for Z-Wave JS.""" +from __future__ import annotations + +from typing import cast + +import voluptuous as vol +from zwave_js_server.const import CommandClass, ConfigurationValueType +from zwave_js_server.model.value import ConfigurationValue + +from homeassistant.const import CONF_CONDITION, CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers.config_validation import DEVICE_CONDITION_BASE_SCHEMA +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import DOMAIN +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_ENDPOINT, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_VALUE, +) +from .helpers import async_get_node_from_device_id, get_zwave_value_from_config + +CONF_SUBTYPE = "subtype" +CONF_VALUE_ID = "value_id" +CONF_STATUS = "status" + +NODE_STATUS_TYPE = "node_status" +NODE_STATUS_TYPES = ["asleep", "awake", "dead", "alive"] +CONFIG_PARAMETER_TYPE = "config_parameter" +VALUE_TYPE = "value" +CONDITION_TYPES = {NODE_STATUS_TYPE, CONFIG_PARAMETER_TYPE, VALUE_TYPE} + +NODE_STATUS_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): NODE_STATUS_TYPE, + vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES), + } +) + +CONFIG_PARAMETER_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): CONFIG_PARAMETER_TYPE, + vol.Required(CONF_VALUE_ID): cv.string, + vol.Required(CONF_SUBTYPE): cv.string, + vol.Optional(ATTR_VALUE): vol.Coerce(int), + } +) + +VALUE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): VALUE_TYPE, + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + vol.Required(ATTR_PROPERTY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_PROPERTY_KEY): vol.Any(vol.Coerce(int), cv.string), + vol.Optional(ATTR_ENDPOINT): vol.Coerce(int), + vol.Required(ATTR_VALUE): vol.Any( + bool, + vol.Coerce(int), + vol.Coerce(float), + cv.boolean, + cv.string, + ), + } +) + +CONDITION_SCHEMA = vol.Any( + NODE_STATUS_CONDITION_SCHEMA, + CONFIG_PARAMETER_CONDITION_SCHEMA, + VALUE_CONDITION_SCHEMA, +) + + +async def async_validate_condition_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + config = CONDITION_SCHEMA(config) + if config[CONF_TYPE] == VALUE_TYPE: + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + get_zwave_value_from_config(node, config) + + return config + + +async def async_get_conditions( + hass: HomeAssistant, device_id: str +) -> list[dict[str, str]]: + """List device conditions for Z-Wave JS devices.""" + conditions = [] + base_condition = { + CONF_CONDITION: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + node = async_get_node_from_device_id(hass, device_id) + + # Any value's value condition + conditions.append({**base_condition, CONF_TYPE: VALUE_TYPE}) + + # Node status conditions + conditions.append({**base_condition, CONF_TYPE: NODE_STATUS_TYPE}) + + # Config parameter conditions + conditions.extend( + [ + { + **base_condition, + CONF_VALUE_ID: config_value.value_id, + CONF_TYPE: CONFIG_PARAMETER_TYPE, + CONF_SUBTYPE: f"{config_value.value_id} ({config_value.property_name})", + } + for config_value in node.get_configuration_values().values() + ] + ) + + return conditions + + +@callback +def async_condition_from_config( + config: ConfigType, config_validation: bool +) -> condition.ConditionCheckerType: + """Create a function to test a device condition.""" + if config_validation: + config = CONDITION_SCHEMA(config) + + condition_type = config[CONF_TYPE] + device_id = config[CONF_DEVICE_ID] + + @callback + def test_node_status(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if node status is a certain state.""" + node = async_get_node_from_device_id(hass, device_id) + return bool(node.status.name.lower() == config[CONF_STATUS]) + + if condition_type == NODE_STATUS_TYPE: + return test_node_status + + @callback + def test_config_parameter(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if config parameter is a certain state.""" + node = async_get_node_from_device_id(hass, device_id) + config_value = cast(ConfigurationValue, node.values[config[CONF_VALUE_ID]]) + return bool(config_value.value == config[ATTR_VALUE]) + + if condition_type == CONFIG_PARAMETER_TYPE: + return test_config_parameter + + @callback + def test_value(hass: HomeAssistant, variables: TemplateVarsType) -> bool: + """Test if value is a certain state.""" + node = async_get_node_from_device_id(hass, device_id) + value = get_zwave_value_from_config(node, config) + return bool(value.value == config[ATTR_VALUE]) + + if condition_type == VALUE_TYPE: + return test_value + + raise HomeAssistantError(f"Unhandled condition type {condition_type}") + + +@callback +async def async_get_condition_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List condition capabilities.""" + device_id = config[CONF_DEVICE_ID] + node = async_get_node_from_device_id(hass, device_id) + + # Add additional fields to the automation trigger UI + if config[CONF_TYPE] == CONFIG_PARAMETER_TYPE: + value_id = config[CONF_VALUE_ID] + config_value = cast(ConfigurationValue, node.values[value_id]) + min_ = config_value.metadata.min + max_ = config_value.metadata.max + + if config_value.configuration_value_type in ( + ConfigurationValueType.RANGE, + ConfigurationValueType.MANUAL_ENTRY, + ): + value_schema = vol.Range(min=min_, max=max_) + elif config_value.configuration_value_type == ConfigurationValueType.ENUMERATED: + value_schema = vol.In( + {int(k): v for k, v in config_value.metadata.states.items()} + ) + else: + return {} + + return {"extra_fields": vol.Schema({vol.Required(ATTR_VALUE): value_schema})} + + if config[CONF_TYPE] == VALUE_TYPE: + return { + "extra_fields": vol.Schema( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In( + {cc.value: cc.name for cc in CommandClass} + ), + vol.Required(ATTR_PROPERTY): cv.string, + vol.Optional(ATTR_PROPERTY_KEY): cv.string, + vol.Optional(ATTR_ENDPOINT): cv.string, + vol.Required(ATTR_VALUE): cv.string, + } + ) + } + + if config[CONF_TYPE] == NODE_STATUS_TYPE: + return { + "extra_fields": vol.Schema( + {vol.Required(CONF_STATUS): vol.In(NODE_STATUS_TYPES)} + ) + } + + return {} diff --git a/homeassistant/components/zwave_js/device_trigger.py b/homeassistant/components/zwave_js/device_trigger.py new file mode 100644 index 00000000000..6d1b611d14f --- /dev/null +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -0,0 +1,372 @@ +"""Provides device triggers for Z-Wave JS.""" +from __future__ import annotations + +import voluptuous as vol +from zwave_js_server.const import CommandClass + +from homeassistant.components.automation import AutomationActionType +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.components.homeassistant.triggers import event, state +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import ( + config_validation as cv, + device_registry, + entity_registry, +) +from homeassistant.helpers.typing import ConfigType + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_DATA_TYPE, + ATTR_ENDPOINT, + ATTR_EVENT, + ATTR_EVENT_LABEL, + ATTR_EVENT_TYPE, + ATTR_LABEL, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + ATTR_TYPE, + ATTR_VALUE, + ATTR_VALUE_RAW, + DOMAIN, + ZWAVE_JS_NOTIFICATION_EVENT, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, +) +from .helpers import ( + async_get_node_from_device_id, + async_get_node_status_sensor_entity_id, + get_zwave_value_from_config, +) + +CONF_SUBTYPE = "subtype" +CONF_VALUE_ID = "value_id" + +# Trigger types +ENTRY_CONTROL_NOTIFICATION = "event.notification.entry_control" +NOTIFICATION_NOTIFICATION = "event.notification.notification" +BASIC_VALUE_NOTIFICATION = "event.value_notification.basic" +CENTRAL_SCENE_VALUE_NOTIFICATION = "event.value_notification.central_scene" +SCENE_ACTIVATION_VALUE_NOTIFICATION = "event.value_notification.scene_activation" +NODE_STATUS = "state.node_status" + +NOTIFICATION_EVENT_CC_MAPPINGS = ( + (ENTRY_CONTROL_NOTIFICATION, CommandClass.ENTRY_CONTROL), + (NOTIFICATION_NOTIFICATION, CommandClass.NOTIFICATION), +) + +# Event based trigger schemas +BASE_EVENT_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(ATTR_COMMAND_CLASS): vol.In([cc.value for cc in CommandClass]), + } +) + +NOTIFICATION_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): NOTIFICATION_NOTIFICATION, + vol.Optional(f"{ATTR_TYPE}."): vol.Coerce(int), + vol.Optional(ATTR_LABEL): cv.string, + vol.Optional(ATTR_EVENT): vol.Coerce(int), + vol.Optional(ATTR_EVENT_LABEL): cv.string, + } +) + +ENTRY_CONTROL_NOTIFICATION_SCHEMA = BASE_EVENT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): ENTRY_CONTROL_NOTIFICATION, + vol.Optional(ATTR_EVENT_TYPE): vol.Coerce(int), + vol.Optional(ATTR_DATA_TYPE): vol.Coerce(int), + } +) + +BASE_VALUE_NOTIFICATION_EVENT_SCHEMA = BASE_EVENT_SCHEMA.extend( + { + vol.Required(ATTR_PROPERTY): vol.Any(int, str), + vol.Required(ATTR_PROPERTY_KEY): vol.Any(None, int, str), + vol.Required(ATTR_ENDPOINT): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +BASIC_VALUE_NOTIFICATION_SCHEMA = BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): BASIC_VALUE_NOTIFICATION, + } +) + +CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA = BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): CENTRAL_SCENE_VALUE_NOTIFICATION, + } +) + +SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA = ( + BASE_VALUE_NOTIFICATION_EVENT_SCHEMA.extend( + { + vol.Required(CONF_TYPE): SCENE_ACTIVATION_VALUE_NOTIFICATION, + } + ) +) + +# State based trigger schemas +BASE_STATE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_ENTITY_ID): cv.entity_id, + } +) + +NODE_STATUSES = ["asleep", "awake", "dead", "alive"] + +NODE_STATUS_SCHEMA = BASE_STATE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): NODE_STATUS, + vol.Optional(state.CONF_FROM): vol.In(NODE_STATUSES), + vol.Optional(state.CONF_TO): vol.In(NODE_STATUSES), + vol.Optional(state.CONF_FOR): cv.positive_time_period_dict, + } +) + +TRIGGER_SCHEMA = vol.Any( + ENTRY_CONTROL_NOTIFICATION_SCHEMA, + NOTIFICATION_NOTIFICATION_SCHEMA, + BASIC_VALUE_NOTIFICATION_SCHEMA, + CENTRAL_SCENE_VALUE_NOTIFICATION_SCHEMA, + SCENE_ACTIVATION_VALUE_NOTIFICATION_SCHEMA, + NODE_STATUS_SCHEMA, +) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> list[dict]: + """List device triggers for Z-Wave JS devices.""" + dev_reg = device_registry.async_get(hass) + node = async_get_node_from_device_id(hass, device_id, dev_reg) + + triggers = [] + base_trigger = { + CONF_PLATFORM: "device", + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + } + + # We can add a node status trigger if the node status sensor is enabled + ent_reg = entity_registry.async_get(hass) + entity_id = async_get_node_status_sensor_entity_id( + hass, device_id, ent_reg, dev_reg + ) + if (entity := ent_reg.async_get(entity_id)) is not None and not entity.disabled: + triggers.append( + {**base_trigger, CONF_TYPE: NODE_STATUS, CONF_ENTITY_ID: entity_id} + ) + + # Handle notification event triggers + triggers.extend( + [ + {**base_trigger, CONF_TYPE: event_type, ATTR_COMMAND_CLASS: command_class} + for event_type, command_class in NOTIFICATION_EVENT_CC_MAPPINGS + if any(cc.id == command_class for cc in node.command_classes) + ] + ) + + # Handle central scene value notification event triggers + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: CENTRAL_SCENE_VALUE_NOTIFICATION, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_ENDPOINT: value.endpoint, + ATTR_COMMAND_CLASS: CommandClass.CENTRAL_SCENE, + CONF_SUBTYPE: f"Endpoint {value.endpoint} Scene {value.property_key}", + } + for value in node.get_command_class_values( + CommandClass.CENTRAL_SCENE + ).values() + if value.property_ == "scene" + ] + ) + + # Handle scene activation value notification event triggers + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: SCENE_ACTIVATION_VALUE_NOTIFICATION, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_ENDPOINT: value.endpoint, + ATTR_COMMAND_CLASS: CommandClass.SCENE_ACTIVATION, + CONF_SUBTYPE: f"Endpoint {value.endpoint}", + } + for value in node.get_command_class_values( + CommandClass.SCENE_ACTIVATION + ).values() + if value.property_ == "sceneId" + ] + ) + + # Handle basic value notification event triggers + # Nodes will only send Basic CC value notifications if a compatibility flag is set + if node.device_config.compat.get("treatBasicSetAsEvent", False): + triggers.extend( + [ + { + **base_trigger, + CONF_TYPE: BASIC_VALUE_NOTIFICATION, + ATTR_PROPERTY: value.property_, + ATTR_PROPERTY_KEY: value.property_key, + ATTR_ENDPOINT: value.endpoint, + ATTR_COMMAND_CLASS: CommandClass.BASIC, + CONF_SUBTYPE: f"Endpoint {value.endpoint}", + } + for value in node.get_command_class_values(CommandClass.BASIC).values() + if value.property_ == "event" + ] + ) + + return triggers + + +def copy_available_params( + input_dict: dict, output_dict: dict, params: list[str] +) -> None: + """Copy available params from input into output.""" + for param in params: + if (val := input_dict.get(param)) not in ("", None): + output_dict[param] = val + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + trigger_type = config[CONF_TYPE] + trigger_platform = trigger_type.split(".")[0] + + event_data = {CONF_DEVICE_ID: config[CONF_DEVICE_ID]} + event_config = { + event.CONF_PLATFORM: "event", + event.CONF_EVENT_DATA: event_data, + } + + if ATTR_COMMAND_CLASS in config: + event_data[ATTR_COMMAND_CLASS] = config[ATTR_COMMAND_CLASS] + + # Take input data from automation trigger UI and add it to the trigger we are + # attaching to + if trigger_platform == "event": + if trigger_type == ENTRY_CONTROL_NOTIFICATION: + event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT + copy_available_params(config, event_data, [ATTR_EVENT_TYPE, ATTR_DATA_TYPE]) + elif trigger_type == NOTIFICATION_NOTIFICATION: + event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_NOTIFICATION_EVENT + copy_available_params( + config, event_data, [ATTR_LABEL, ATTR_EVENT_LABEL, ATTR_EVENT] + ) + if (val := config.get(f"{ATTR_TYPE}.")) not in ("", None): + event_data[ATTR_TYPE] = val + elif trigger_type in ( + BASIC_VALUE_NOTIFICATION, + CENTRAL_SCENE_VALUE_NOTIFICATION, + SCENE_ACTIVATION_VALUE_NOTIFICATION, + ): + event_config[event.CONF_EVENT_TYPE] = ZWAVE_JS_VALUE_NOTIFICATION_EVENT + copy_available_params( + config, event_data, [ATTR_PROPERTY, ATTR_PROPERTY_KEY, ATTR_ENDPOINT] + ) + if ATTR_VALUE in config: + event_data[ATTR_VALUE_RAW] = config[ATTR_VALUE] + else: + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + + event_config = event.TRIGGER_SCHEMA(event_config) + return await event.async_attach_trigger( + hass, event_config, action, automation_info, platform_type="device" + ) + + state_config = {state.CONF_PLATFORM: "state"} + + if trigger_platform == "state" and trigger_type == NODE_STATUS: + state_config[state.CONF_ENTITY_ID] = config[CONF_ENTITY_ID] + copy_available_params( + config, state_config, [state.CONF_FOR, state.CONF_FROM, state.CONF_TO] + ) + + state_config = state.TRIGGER_SCHEMA(state_config) + return await state.async_attach_trigger( + hass, state_config, action, automation_info, platform_type="device" + ) + + raise HomeAssistantError(f"Unhandled trigger type {trigger_type}") + + +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List trigger capabilities.""" + node = async_get_node_from_device_id(hass, config[CONF_DEVICE_ID]) + value = ( + get_zwave_value_from_config(node, config) if ATTR_PROPERTY in config else None + ) + # Add additional fields to the automation trigger UI + if config[CONF_TYPE] == NOTIFICATION_NOTIFICATION: + return { + "extra_fields": vol.Schema( + { + vol.Optional(f"{ATTR_TYPE}."): cv.string, + vol.Optional(ATTR_LABEL): cv.string, + vol.Optional(ATTR_EVENT): cv.string, + vol.Optional(ATTR_EVENT_LABEL): cv.string, + } + ) + } + + if config[CONF_TYPE] == ENTRY_CONTROL_NOTIFICATION: + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_EVENT_TYPE): cv.string, + vol.Optional(ATTR_DATA_TYPE): cv.string, + } + ) + } + + if config[CONF_TYPE] == NODE_STATUS: + return { + "extra_fields": vol.Schema( + { + vol.Optional(state.CONF_FROM): vol.In(NODE_STATUSES), + vol.Optional(state.CONF_TO): vol.In(NODE_STATUSES), + vol.Optional(state.CONF_FOR): cv.positive_time_period_dict, + } + ) + } + + if config[CONF_TYPE] in ( + BASIC_VALUE_NOTIFICATION, + CENTRAL_SCENE_VALUE_NOTIFICATION, + SCENE_ACTIVATION_VALUE_NOTIFICATION, + ): + if value.metadata.states: + value_schema = vol.In({int(k): v for k, v in value.metadata.states.items()}) + else: + value_schema = vol.All( + vol.Coerce(int), + vol.Range(min=value.metadata.min, max=value.metadata.max), + ) + + return {"extra_fields": vol.Schema({vol.Optional(ATTR_VALUE): value_schema})} + + return {} diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 29976850480..588b4c76472 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -67,6 +67,8 @@ class ZwaveDiscoveryInfo: platform_data: dict[str, Any] | None = None # additional values that need to be watched by entity additional_value_ids_to_watch: set[str] | None = None + # bool to specify whether entity should be enabled by default + entity_registry_enabled_default: bool = True @dataclass @@ -135,6 +137,8 @@ class ZWaveDiscoverySchema: allow_multi: bool = False # [optional] bool to specify whether state is assumed and events should be fired on value update assumed_state: bool = False + # [optional] bool to specify whether entity should be enabled by default + entity_registry_enabled_default: bool = True def get_config_parameter_discovery_schema( @@ -161,6 +165,7 @@ def get_config_parameter_discovery_schema( property_key_name=property_key_name, type={"number"}, ), + entity_registry_enabled_default=False, **kwargs, ) @@ -175,6 +180,10 @@ SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_BINARY}, property={"currentValue"} ) +SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.SOUND_SWITCH}, property={"toneId"}, type={"number"} +) + # For device class mapping see: # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json DISCOVERY_SCHEMAS = [ @@ -424,12 +433,33 @@ DISCOVERY_SCHEMAS = [ ], ), # binary sensors + # When CC is Sensor Binary and device class generic is Binary Sensor, entity should + # be enabled by default + ZWaveDiscoverySchema( + platform="binary_sensor", + hint="boolean", + device_class_generic={"Binary Sensor"}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + type={"boolean"}, + ), + ), + # Legacy binary sensors are phased out (replaced by notification sensors) + # Disable by default to not confuse users + ZWaveDiscoverySchema( + platform="binary_sensor", + hint="boolean", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SENSOR_BINARY}, + type={"boolean"}, + ), + entity_registry_enabled_default=False, + ), ZWaveDiscoverySchema( platform="binary_sensor", hint="boolean", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.SENSOR_BINARY, CommandClass.BATTERY, CommandClass.SENSOR_ALARM, }, @@ -452,13 +482,19 @@ DISCOVERY_SCHEMAS = [ platform="sensor", hint="string_sensor", primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, - }, + command_class={CommandClass.SENSOR_ALARM}, type={"string"}, ), ), + ZWaveDiscoverySchema( + platform="sensor", + hint="string_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={"string"}, + ), + entity_registry_enabled_default=False, + ), # generic numeric sensors ZWaveDiscoverySchema( platform="sensor", @@ -467,16 +503,24 @@ DISCOVERY_SCHEMAS = [ command_class={ CommandClass.SENSOR_MULTILEVEL, CommandClass.SENSOR_ALARM, - CommandClass.INDICATOR, CommandClass.BATTERY, }, type={"number"}, ), ), - # numeric sensors for Meter CC ZWaveDiscoverySchema( platform="sensor", hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={"number"}, + ), + entity_registry_enabled_default=False, + ), + # Meter sensors for Meter CC + ZWaveDiscoverySchema( + platform="sensor", + hint="meter", primary_value=ZWaveValueDiscoverySchema( command_class={ CommandClass.METER, @@ -496,6 +540,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # sensor for basic CC ZWaveDiscoverySchema( @@ -508,6 +553,7 @@ DISCOVERY_SCHEMAS = [ type={"number"}, property={"currentValue"}, ), + entity_registry_enabled_default=False, ), # binary switches ZWaveDiscoverySchema( @@ -582,6 +628,11 @@ DISCOVERY_SCHEMAS = [ platform="light", primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # sirens + ZWaveDiscoverySchema( + platform="siren", + primary_value=SIREN_TONE_SCHEMA, + ), ] @@ -688,6 +739,7 @@ def async_discover_values(node: ZwaveNode) -> Generator[ZwaveDiscoveryInfo, None platform_data_template=schema.data_template, platform_data=resolved_data, additional_value_ids_to_watch=additional_value_ids_to_watch, + entity_registry_enabled_default=schema.entity_registry_enabled_default, ) if not schema.allow_multi: diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 548796911af..793eaa435d5 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import NodeStatus from zwave_js_server.model.value import Value as ZwaveValue, get_value_id from homeassistant.config_entries import ConfigEntry @@ -18,6 +19,8 @@ from .helpers import get_device_id, get_unique_id LOGGER = logging.getLogger(__name__) EVENT_VALUE_UPDATED = "value updated" +EVENT_DEAD = "dead" +EVENT_ALIVE = "alive" class ZWaveBaseEntity(Entity): @@ -43,6 +46,9 @@ class ZWaveBaseEntity(Entity): self._attr_unique_id = get_unique_id( self.client.driver.controller.home_id, self.info.primary_value.value_id ) + self._attr_entity_registry_enabled_default = ( + self.info.entity_registry_enabled_default + ) self._attr_assumed_state = self.info.assumed_state # device is precreated in main handler self._attr_device_info = { @@ -90,6 +96,11 @@ class ZWaveBaseEntity(Entity): self.async_on_remove( self.info.node.on(EVENT_VALUE_UPDATED, self._value_changed) ) + for status_event in (EVENT_ALIVE, EVENT_DEAD): + self.async_on_remove( + self.info.node.on(status_event, self._node_status_alive_or_dead) + ) + self.async_on_remove( async_dispatcher_connect( self.hass, @@ -135,7 +146,20 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return self.client.connected and bool(self.info.node.ready) + return ( + self.client.connected + and bool(self.info.node.ready) + and self.info.node.status != NodeStatus.DEAD + ) + + @callback + def _node_status_alive_or_dead(self, event_data: dict) -> None: + """ + Call when node status changes to alive or dead. + + Should not be overridden by subclasses. + """ + self.async_write_ha_state() @callback def _value_changed(self, event_data: dict) -> None: @@ -192,13 +216,13 @@ class ZWaveBaseEntity(Entity): # If we haven't found a value and check_all_endpoints is True, we should # return the first value we can find on any other endpoint if return_value is None and check_all_endpoints: - for endpoint_ in self.info.node.endpoints: - if endpoint_.index != self.info.primary_value.endpoint: + for endpoint_idx in self.info.node.endpoints: + if endpoint_idx != self.info.primary_value.endpoint: value_id = get_value_id( self.info.node, command_class, value_property, - endpoint=endpoint_.index, + endpoint=endpoint_idx, property_key=value_property_key, ) return_value = self.info.node.values.get(value_id) diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 89b99e90110..71f483c548f 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -21,7 +21,7 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -45,7 +45,7 @@ async def async_setup_entry( entities.append(ZwaveFan(config_entry, client, info)) async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{FAN_DOMAIN}", diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 81eae0fdc15..593d5ea4151 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -3,13 +3,16 @@ from __future__ import annotations from typing import Any, cast +import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue +from zwave_js_server.model.value import Value as ZwaveValue, get_value_id +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import ( DeviceRegistry, async_get as async_get_dev_reg, @@ -18,8 +21,17 @@ from homeassistant.helpers.entity_registry import ( EntityRegistry, async_get as async_get_ent_reg, ) +from homeassistant.helpers.typing import ConfigType -from .const import CONF_DATA_COLLECTION_OPTED_IN, DATA_CLIENT, DOMAIN +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_ENDPOINT, + ATTR_PROPERTY, + ATTR_PROPERTY_KEY, + CONF_DATA_COLLECTION_OPTED_IN, + DATA_CLIENT, + DOMAIN, +) @callback @@ -79,7 +91,7 @@ def async_get_node_from_device_id( device_entry = dev_reg.async_get(device_id) if not device_entry: - raise ValueError("Device ID is not valid") + raise ValueError(f"Device ID {device_id} is not valid") # Use device config entry ID's to validate that this is a valid zwave_js device # and to get the client @@ -97,7 +109,9 @@ def async_get_node_from_device_id( None, ) if config_entry_id is None or config_entry_id not in hass.data[DOMAIN]: - raise ValueError("Device is not from an existing zwave_js config entry") + raise ValueError( + f"Device {device_id} is not from an existing zwave_js config entry" + ) client = hass.data[DOMAIN][config_entry_id][DATA_CLIENT] @@ -115,7 +129,7 @@ def async_get_node_from_device_id( node_id = int(identifier[1]) if identifier is not None else None if node_id is None or node_id not in client.driver.controller.nodes: - raise ValueError("Device node can't be found") + raise ValueError(f"Node for device {device_id} can't be found") return client.driver.controller.nodes[node_id] @@ -143,3 +157,55 @@ def async_get_node_from_entity_id( # tied to a device assert entity_entry.device_id return async_get_node_from_device_id(hass, entity_entry.device_id, dev_reg) + + +def get_zwave_value_from_config(node: ZwaveNode, config: ConfigType) -> ZwaveValue: + """Get a Z-Wave JS Value from a config.""" + endpoint = None + if config.get(ATTR_ENDPOINT): + endpoint = config[ATTR_ENDPOINT] + property_key = None + if config.get(ATTR_PROPERTY_KEY): + property_key = config[ATTR_PROPERTY_KEY] + value_id = get_value_id( + node, + config[ATTR_COMMAND_CLASS], + config[ATTR_PROPERTY], + endpoint, + property_key, + ) + if value_id not in node.values: + raise vol.Invalid(f"Value {value_id} can't be found on node {node}") + return node.values[value_id] + + +@callback +def async_get_node_status_sensor_entity_id( + hass: HomeAssistant, + device_id: str, + ent_reg: EntityRegistry | None = None, + dev_reg: DeviceRegistry | None = None, +) -> str: + """Get the node status sensor entity ID for a given Z-Wave JS device.""" + if not ent_reg: + ent_reg = async_get_ent_reg(hass) + if not dev_reg: + dev_reg = async_get_dev_reg(hass) + device = dev_reg.async_get(device_id) + if not device: + raise HomeAssistantError("Invalid Device ID provided") + + entry_id = next(entry_id for entry_id in device.config_entries) + client = hass.data[DOMAIN][entry_id][DATA_CLIENT] + node = async_get_node_from_device_id(hass, device_id, dev_reg) + entity_id = ent_reg.async_get_entity_id( + SENSOR_DOMAIN, + DOMAIN, + f"{client.driver.controller.home_id}.{node.node_id}.node_status", + ) + if not entity_id: + raise HomeAssistantError( + "Node status sensor entity not found. Device may not be a zwave_js device" + ) + + return entity_id diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index b50f2231f46..f3cabe8b6a7 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -27,7 +27,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util -from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -44,6 +44,8 @@ MULTI_COLOR_MAP = { ColorComponent.PURPLE: "purple", } +TRANSITION_DURATION = "transitionDuration" + async def async_setup_entry( hass: HomeAssistant, @@ -60,7 +62,7 @@ async def async_setup_entry( light = ZwaveLight(config_entry, client, info) async_add_entities([light]) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{LIGHT_DOMAIN}", @@ -109,8 +111,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes = set() # get additional (optional) values and set features - self._target_value = self.get_zwave_value("targetValue") - self._dimming_duration = self.get_zwave_value("duration") + self._target_brightness = self.get_zwave_value( + "targetValue", add_to_watched_value_ids=False + ) + self._target_color = self.get_zwave_value( + "targetColor", CommandClass.SWITCH_COLOR, add_to_watched_value_ids=False + ) + self._calculate_color_values() if self._supports_rgbw: self._supported_color_modes.add(COLOR_MODE_RGBW) @@ -123,7 +130,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # Entity class attributes self._attr_supported_features = 0 - if self._dimming_duration is not None: + self.supports_brightness_transition = bool( + self._target_brightness is not None + and TRANSITION_DURATION + in self._target_brightness.metadata.value_change_options + ) + self.supports_color_transition = bool( + self._target_color is not None + and TRANSITION_DURATION in self._target_color.metadata.value_change_options + ) + + if self.supports_brightness_transition or self.supports_color_transition: self._attr_supported_features |= SUPPORT_TRANSITION @callback @@ -183,6 +200,9 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" + + transition = kwargs.get(ATTR_TRANSITION) + # RGB/HS color hs_color = kwargs.get(ATTR_HS_COLOR) if hs_color is not None and self._supports_color: @@ -196,7 +216,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # turn of white leds when setting rgb colors[ColorComponent.WARM_WHITE] = 0 colors[ColorComponent.COLD_WHITE] = 0 - await self._async_set_colors(colors) + await self._async_set_colors(colors, transition) # Color temperature color_temp = kwargs.get(ATTR_COLOR_TEMP) @@ -222,7 +242,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): ColorComponent.BLUE: 0, ColorComponent.WARM_WHITE: warm, ColorComponent.COLD_WHITE: cold, - } + }, + transition, ) # RGBW @@ -238,18 +259,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self._cold_white: rgbw_channels[ColorComponent.COLD_WHITE] = rgbw[3] - await self._async_set_colors(rgbw_channels) + await self._async_set_colors(rgbw_channels, transition) # set brightness - await self._async_set_brightness( - kwargs.get(ATTR_BRIGHTNESS), kwargs.get(ATTR_TRANSITION) - ) + await self._async_set_brightness(kwargs.get(ATTR_BRIGHTNESS), transition) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" await self._async_set_brightness(0, kwargs.get(ATTR_TRANSITION)) - async def _async_set_colors(self, colors: dict[ColorComponent, int]) -> None: + async def _async_set_colors( + self, colors: dict[ColorComponent, int], transition: float | None = None + ) -> None: """Set (multiple) defined colors to given value(s).""" # prefer the (new) combined color property # https://github.com/zwave-js/node-zwave-js/pull/1782 @@ -258,21 +279,36 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): CommandClass.SWITCH_COLOR, value_property_key=None, ) + zwave_transition = None + + if self.supports_color_transition: + if transition is not None: + zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} + else: + zwave_transition = {TRANSITION_DURATION: "default"} + if combined_color_val and isinstance(combined_color_val.value, dict): colors_dict = {} for color, value in colors.items(): color_name = MULTI_COLOR_MAP[color] colors_dict[color_name] = value # set updated color object - await self.info.node.async_set_value(combined_color_val, colors_dict) + await self.info.node.async_set_value( + combined_color_val, colors_dict, zwave_transition + ) return # fallback to setting the color(s) one by one if multicolor fails # not sure this is needed at all, but just in case for color, value in colors.items(): - await self._async_set_color(color, value) + await self._async_set_color(color, value, zwave_transition) - async def _async_set_color(self, color: ColorComponent, new_value: int) -> None: + async def _async_set_color( + self, + color: ColorComponent, + new_value: int, + transition: dict[str, str] | None = None, + ) -> None: """Set defined color to given value.""" # actually set the new color value target_zwave_value = self.get_zwave_value( @@ -283,10 +319,10 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if target_zwave_value is None: # guard for unsupported color return - await self.info.node.async_set_value(target_zwave_value, new_value) + await self.info.node.async_set_value(target_zwave_value, new_value, transition) async def _async_set_brightness( - self, brightness: int | None, transition: int | None = None + self, brightness: int | None, transition: float | None = None ) -> None: """Set new brightness to light.""" if brightness is None: @@ -297,40 +333,17 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_brightness = byte_to_zwave_brightness(brightness) # set transition value before sending new brightness - await self._async_set_transition_duration(transition) - # setting a value requires setting targetValue - await self.info.node.async_set_value(self._target_value, zwave_brightness) - - async def _async_set_transition_duration(self, duration: int | None = None) -> None: - """Set the transition time for the brightness value.""" - if self._dimming_duration is None: - return - # pylint: disable=fixme,unreachable - # TODO: setting duration needs to be fixed upstream - # https://github.com/zwave-js/node-zwave-js/issues/1321 - return - - if duration is None: # type: ignore - # no transition specified by user, use defaults - duration = 7621 # anything over 7620 uses the factory default - else: # pragma: no cover - # transition specified by user - transition = duration - if transition <= 127: - duration = transition + zwave_transition = None + if self.supports_brightness_transition: + if transition is not None: + zwave_transition = {TRANSITION_DURATION: f"{int(transition)}s"} else: - minutes = round(transition / 60) - LOGGER.debug( - "Transition rounded to %d minutes for %s", - minutes, - self.entity_id, - ) - duration = minutes + 128 + zwave_transition = {TRANSITION_DURATION: "default"} - # only send value if it differs from current - # this prevents sending a command for nothing - if self._dimming_duration.value != duration: # pragma: no cover - await self.info.node.async_set_value(self._dimming_duration, duration) + # setting a value requires setting targetValue + await self.info.node.async_set_value( + self._target_brightness, zwave_brightness, zwave_transition + ) @callback def _calculate_color_values(self) -> None: diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 42230a9c267..ad4a736d63e 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -25,7 +25,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -62,7 +62,7 @@ async def async_setup_entry( async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{LOCK_DOMAIN}", async_add_lock ) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index d719e3976a4..b24bc957303 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.27.1"], + "requirements": ["zwave-js-server-python==0.28.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"], "iot_class": "local_push" diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 2a3c9820a69..e53e5942999 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -9,7 +9,7 @@ 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_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -29,7 +29,7 @@ async def async_setup_entry( entities.append(ZwaveNumberEntity(config_entry, client, info)) async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{NUMBER_DOMAIN}", diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 064275e5729..7b491661e68 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -4,12 +4,14 @@ from __future__ import annotations import logging from typing import cast +import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass, ConfigurationValueType from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ConfigurationValue from homeassistant.components.sensor import ( + ATTR_LAST_RESET, DEVICE_CLASS_BATTERY, DEVICE_CLASS_ENERGY, DEVICE_CLASS_ILLUMINANCE, @@ -20,16 +22,24 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + DEVICE_CLASS_CURRENT, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers import entity_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt -from .const import DATA_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import ATTR_METER_TYPE, ATTR_VALUE, DATA_CLIENT, DOMAIN, SERVICE_RESET_METER from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_id @@ -58,6 +68,8 @@ async def async_setup_entry( entities.append(ZWaveListSensor(config_entry, client, info)) elif info.platform_hint == "config_parameter": entities.append(ZWaveConfigParameterSensor(config_entry, client, info)) + elif info.platform_hint == "meter": + entities.append(ZWaveMeterSensor(config_entry, client, info)) else: LOGGER.warning( "Sensor not implemented for %s/%s", @@ -73,7 +85,7 @@ async def async_setup_entry( """Add node status sensor.""" async_add_entities([ZWaveNodeStatusSensor(config_entry, client, node)]) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{SENSOR_DOMAIN}", @@ -81,7 +93,7 @@ async def async_setup_entry( ) ) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_node_status_sensor", @@ -89,6 +101,16 @@ async def async_setup_entry( ) ) + platform = entity_platform.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_RESET_METER, + { + vol.Optional(ATTR_METER_TYPE): vol.Coerce(int), + vol.Optional(ATTR_VALUE): vol.Coerce(int), + }, + "async_reset_meter", + ) + class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): """Basic Representation of a Z-Wave sensor.""" @@ -106,15 +128,6 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): self._attr_name = self.generate_name(include_value_name=True) self._attr_device_class = self._get_device_class() self._attr_state_class = self._get_state_class() - self._attr_entity_registry_enabled_default = True - # We hide some of the more advanced sensors by default to not overwhelm users - if self.info.primary_value.command_class in [ - CommandClass.BASIC, - CommandClass.CONFIGURATION, - CommandClass.INDICATOR, - CommandClass.NOTIFICATION, - ]: - self._attr_entity_registry_enabled_default = False def _get_device_class(self) -> str | None: """ @@ -125,18 +138,20 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): """ if self.info.primary_value.command_class == CommandClass.BATTERY: return DEVICE_CLASS_BATTERY - if self.info.primary_value.command_class == CommandClass.METER: - if self.info.primary_value.metadata.unit == "kWh": - return DEVICE_CLASS_ENERGY - return DEVICE_CLASS_POWER if isinstance(self.info.primary_value.property_, str): property_lower = self.info.primary_value.property_.lower() if "humidity" in property_lower: return DEVICE_CLASS_HUMIDITY if "temperature" in property_lower: return DEVICE_CLASS_TEMPERATURE + if self.info.primary_value.metadata.unit == "A": + return DEVICE_CLASS_CURRENT if self.info.primary_value.metadata.unit == "W": return DEVICE_CLASS_POWER + if self.info.primary_value.metadata.unit == "kWh": + return DEVICE_CLASS_ENERGY + if self.info.primary_value.metadata.unit == "V": + return DEVICE_CLASS_VOLTAGE if self.info.primary_value.metadata.unit == "Lux": return DEVICE_CLASS_ILLUMINANCE return None @@ -219,6 +234,97 @@ class ZWaveNumericSensor(ZwaveSensorBase): return str(self.info.primary_value.metadata.unit) +class ZWaveMeterSensor(ZWaveNumericSensor, RestoreEntity): + """Representation of a Z-Wave Meter CC sensor.""" + + def __init__( + self, + config_entry: ConfigEntry, + client: ZwaveClient, + info: ZwaveDiscoveryInfo, + ) -> None: + """Initialize a ZWaveNumericSensor entity.""" + super().__init__(config_entry, client, info) + + # Entity class attributes + self._attr_state_class = STATE_CLASS_MEASUREMENT + if self.device_class == DEVICE_CLASS_ENERGY: + self._attr_last_reset = dt.utc_from_timestamp(0) + + @callback + def async_update_last_reset( + self, node: ZwaveNode, endpoint: int, meter_type: int | None + ) -> None: + """Update last reset.""" + # If the signal is not for this node or is for a different endpoint, + # or a meter type was specified and doesn't match this entity's meter type: + if ( + self.info.node != node + or self.info.primary_value.endpoint != endpoint + or meter_type is not None + and self.info.primary_value.metadata.cc_specific.get("meterType") + != meter_type + ): + return + + self._attr_last_reset = dt.utcnow() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + + # If the meter is not an accumulating meter type, do not reset. + if self.device_class != DEVICE_CLASS_ENERGY: + return + + # Restore the last reset time from stored state + restored_state = await self.async_get_last_state() + if restored_state and ATTR_LAST_RESET in restored_state.attributes: + self._attr_last_reset = dt.parse_datetime( + restored_state.attributes[ATTR_LAST_RESET] + ) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{SERVICE_RESET_METER}", + self.async_update_last_reset, + ) + ) + + async def async_reset_meter( + self, meter_type: int | None = None, value: int | None = None + ) -> None: + """Reset meter(s) on device.""" + node = self.info.node + primary_value = self.info.primary_value + options = {} + if meter_type is not None: + options["type"] = meter_type + if value is not None: + options["targetValue"] = value + args = [options] if options else [] + await node.endpoints[primary_value.endpoint].async_invoke_cc_api( + CommandClass.METER, "reset", *args, wait_for_result=False + ) + LOGGER.debug( + "Meters on node %s endpoint %s reset with the following options: %s", + node, + primary_value.endpoint, + options, + ) + + # Notify meters that may have been reset + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{SERVICE_RESET_METER}", + node, + primary_value.endpoint, + options.get("type"), + ) + + class ZWaveListSensor(ZwaveSensorBase): """Representation of a Z-Wave Numeric sensor with multiple states.""" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 47709a908ed..fa0e93a72aa 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -34,8 +34,9 @@ def parameter_name_does_not_need_bitmask( val: dict[str, int | str | list[str]] ) -> dict[str, int | str | list[str]]: """Validate that if a parameter name is provided, bitmask is not as well.""" - if isinstance(val[const.ATTR_CONFIG_PARAMETER], str) and ( - val.get(const.ATTR_CONFIG_PARAMETER_BITMASK) + if ( + isinstance(val[const.ATTR_CONFIG_PARAMETER], str) + and const.ATTR_CONFIG_PARAMETER_BITMASK in val ): raise vol.Invalid( "Don't include a bitmask when a parameter name is specified", @@ -94,25 +95,24 @@ class ZWaveServices: def get_nodes_from_service_data(val: dict[str, Any]) -> dict[str, Any]: """Get nodes set from service data.""" nodes: set[ZwaveNode] = set() - try: - if ATTR_ENTITY_ID in val: - nodes |= { + for entity_id in val.pop(ATTR_ENTITY_ID, []): + try: + nodes.add( async_get_node_from_entity_id( self._hass, entity_id, self._ent_reg, self._dev_reg ) - for entity_id in val[ATTR_ENTITY_ID] - } - val.pop(ATTR_ENTITY_ID) - if ATTR_DEVICE_ID in val: - nodes |= { + ) + except ValueError as err: + const.LOGGER.warning(err.args[0]) + for device_id in val.pop(ATTR_DEVICE_ID, []): + try: + nodes.add( async_get_node_from_device_id( self._hass, device_id, self._dev_reg ) - for device_id in val[ATTR_DEVICE_ID] - } - val.pop(ATTR_DEVICE_ID) - except ValueError as err: - raise vol.Invalid(err.args[0]) from err + ) + except ValueError as err: + const.LOGGER.warning(err.args[0]) val[const.ATTR_NODES] = nodes return val @@ -124,22 +124,16 @@ class ZWaveServices: broadcast: bool = val[const.ATTR_BROADCAST] # User must specify a node if they are attempting a broadcast and have more - # than one zwave-js network. We know it's a broadcast if the nodes list is - # empty because of schema validation. + # than one zwave-js network. if ( - not nodes + broadcast + and not nodes and len(self._hass.config_entries.async_entries(const.DOMAIN)) > 1 ): raise vol.Invalid( "You must include at least one entity or device in the service call" ) - # When multicasting, user must specify at least two nodes - if not broadcast and len(nodes) < 2: - raise vol.Invalid( - "To set a value on a single node, use the zwave_js.set_value service" - ) - first_node = next((node for node in nodes), None) # If any nodes don't have matching home IDs, we can't run the command because @@ -260,6 +254,7 @@ class ZWaveServices: vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, vol.Optional(const.ATTR_WAIT_FOR_RESULT): cv.boolean, + vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), get_nodes_from_service_data, @@ -288,6 +283,7 @@ class ZWaveServices: ), vol.Optional(const.ATTR_ENDPOINT): vol.Coerce(int), vol.Required(const.ATTR_VALUE): VALUE_SCHEMA, + vol.Optional(const.ATTR_OPTIONS): {cv.string: VALUE_SCHEMA}, }, vol.Any( cv.has_at_least_one_key(ATTR_DEVICE_ID, ATTR_ENTITY_ID), @@ -387,6 +383,7 @@ class ZWaveServices: endpoint = service.data.get(const.ATTR_ENDPOINT) new_value = service.data[const.ATTR_VALUE] wait_for_result = service.data.get(const.ATTR_WAIT_FOR_RESULT) + options = service.data.get(const.ATTR_OPTIONS) for node in nodes: success = await node.async_set_value( @@ -398,6 +395,7 @@ class ZWaveServices: property_key=property_key, ), new_value, + options=options, wait_for_result=wait_for_result, ) @@ -412,6 +410,15 @@ class ZWaveServices: """Set a value via multicast to multiple nodes.""" nodes = service.data[const.ATTR_NODES] broadcast: bool = service.data[const.ATTR_BROADCAST] + options = service.data.get(const.ATTR_OPTIONS) + + if not broadcast and len(nodes) == 1: + const.LOGGER.warning( + "Passing the zwave_js.multicast_set_value service call to the " + "zwave_js.set_value service since only one node was targeted" + ) + await self.async_set_value(service) + return value = { "commandClass": service.data[const.ATTR_COMMAND_CLASS], @@ -433,10 +440,11 @@ class ZWaveServices: client = self._hass.data[const.DOMAIN][entry_id][const.DATA_CLIENT] success = await async_multicast_set_value( - client, - new_value, - {k: v for k, v in value.items() if v is not None}, - None if broadcast else list(nodes), + client=client, + new_value=new_value, + value_data={k: v for k, v in value.items() if v is not None}, + nodes=None if broadcast else list(nodes), + options=options, ) if success is False: @@ -445,4 +453,4 @@ class ZWaveServices: async def async_ping(self, service: ServiceCall) -> None: """Ping node(s).""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] - await asyncio.gather(*[node.async_ping() for node in nodes]) + await asyncio.gather(*(node.async_ping() for node in nodes)) diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index c24fa4694cf..b41a893c7e4 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -154,6 +154,12 @@ set_value: required: true selector: object: + options: + name: Options + description: Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set. + required: false + selector: + object: wait_for_result: name: Wait for result? description: Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the service call can take a while if setting a value on an asleep battery device. @@ -203,6 +209,12 @@ multicast_set_value: required: false selector: text: + options: + name: Options + description: Set value options map. Refer to the Z-Wave JS documentation for more information on what options can be set. + required: false + selector: + object: value: name: Value description: The new value to set. @@ -217,3 +229,26 @@ ping: target: entity: integration: zwave_js + +reset_meter: + name: Reset meter(s) on a node + description: Resets the meter(s) on a node. + target: + entity: + domain: sensor + integration: zwave_js + fields: + meter_type: + name: Meter Type + description: The type of meter to reset. Not all meters support the ability to pick a meter type to reset. + example: 1 + required: false + selector: + text: + value: + name: Target Value + description: The value that meter(s) should be reset to. Not all meters support the ability to be reset to a specific value. + example: 5 + required: false + selector: + text: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py new file mode 100644 index 00000000000..de74f55fa9a --- /dev/null +++ b/homeassistant/components/zwave_js/siren.py @@ -0,0 +1,106 @@ +"""Support for Z-Wave controls using the siren platform.""" +from __future__ import annotations + +from typing import Any + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import ToneID + +from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN, SirenEntity +from homeassistant.components.siren.const import ( + ATTR_TONE, + ATTR_VOLUME_LEVEL, + SUPPORT_TONES, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, +) +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_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + 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] + + @callback + def async_add_siren(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave siren entity.""" + entities: list[ZWaveBaseEntity] = [] + entities.append(ZwaveSirenEntity(config_entry, client, info)) + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{SIREN_DOMAIN}", + async_add_siren, + ) + ) + + +class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): + """Representation of a Z-Wave siren entity.""" + + def __init__( + self, config_entry: ConfigEntry, client: ZwaveClient, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveSirenEntity entity.""" + super().__init__(config_entry, client, info) + # Entity class attributes + self._attr_available_tones = list( + self.info.primary_value.metadata.states.values() + ) + self._attr_supported_features = ( + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET + ) + if self._attr_available_tones: + self._attr_supported_features |= SUPPORT_TONES + + @property + def is_on(self) -> bool: + """Return whether device is on.""" + return bool(self.info.primary_value.value) + + async def async_set_value( + self, new_value: int, options: dict[str, Any] | None = None + ) -> None: + """Set a value on a siren node.""" + await self.info.node.async_set_value( + self.info.primary_value, new_value, options=options + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + tone: str | None = kwargs.get(ATTR_TONE) + options = {} + if (volume := kwargs.get(ATTR_VOLUME_LEVEL)) is not None: + options["volume"] = round(volume * 100) + # Play the default tone if a tone isn't provided + if tone is None: + await self.async_set_value(ToneID.DEFAULT, options) + return + + tone_id = int( + next( + key + for key, value in self.info.primary_value.metadata.states.items() + if value == tone + ) + ) + + await self.async_set_value(tone_id, options) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.async_set_value(ToneID.OFF) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index b942a75b27a..628451a6215 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -1,5 +1,4 @@ { - "title": "Z-Wave JS", "config": { "step": { "manual": { @@ -10,7 +9,9 @@ "on_supervisor": { "title": "Select connection method", "description": "Do you want to use the Z-Wave JS Supervisor add-on?", - "data": { "use_addon": "Use the Z-Wave JS Supervisor add-on" } + "data": { + "use_addon": "Use the Z-Wave JS Supervisor add-on" + } }, "install_addon": { "title": "The Z-Wave JS add-on installation has started" @@ -22,7 +23,9 @@ "network_key": "Network Key" } }, - "start_addon": { "title": "The Z-Wave JS add-on is starting." }, + "start_addon": { + "title": "The Z-Wave JS add-on is starting." + }, "hassio_confirm": { "title": "Set up Z-Wave JS integration with the Z-Wave JS add-on" } @@ -93,5 +96,20 @@ "install_addon": "Please wait while the Z-Wave JS add-on installation finishes. This can take several minutes.", "start_addon": "Please wait while the Z-Wave JS add-on start completes. This may take some seconds." } + }, + "device_automation": { + "trigger_type": { + "event.notification.entry_control": "Sent an Entry Control notification", + "event.notification.notification": "Sent a notification", + "event.value_notification.basic": "Basic CC event on {subtype}", + "event.value_notification.central_scene": "Central Scene action on {subtype}", + "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "state.node_status": "Node status changed" + }, + "condition_type": { + "node_status": "Node status", + "config_parameter": "Config parameter {subtype} value", + "value": "Current value of a Z-Wave Value" + } } } diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 1fb5480f2a1..0bc6b8d5349 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -5,6 +5,7 @@ import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.const import BarrierEventSignalingSubsystemState from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -12,17 +13,13 @@ 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_CLIENT, DATA_UNSUBSCRIBE, DOMAIN +from .const import DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity LOGGER = logging.getLogger(__name__) -BARRIER_EVENT_SIGNALING_OFF = 0 -BARRIER_EVENT_SIGNALING_ON = 255 - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -44,7 +41,7 @@ async def async_setup_entry( async_add_entities(entities) - hass.data[DOMAIN][config_entry.entry_id][DATA_UNSUBSCRIBE].append( + config_entry.async_on_unload( async_dispatcher_connect( hass, f"{DOMAIN}_{config_entry.entry_id}_add_{SWITCH_DOMAIN}", @@ -108,7 +105,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.info.node.async_set_value( - self.info.primary_value, BARRIER_EVENT_SIGNALING_ON + self.info.primary_value, BarrierEventSignalingSubsystemState.ON ) # this value is not refreshed, so assume success self._state = True @@ -117,7 +114,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.info.node.async_set_value( - self.info.primary_value, BARRIER_EVENT_SIGNALING_OFF + self.info.primary_value, BarrierEventSignalingSubsystemState.OFF ) # this value is not refreshed, so assume success self._state = False @@ -127,4 +124,6 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): def _update_state(self) -> None: self._state = None if self.info.primary_value.value is not None: - self._state = self.info.primary_value.value == BARRIER_EVENT_SIGNALING_ON + self._state = ( + self.info.primary_value.value == BarrierEventSignalingSubsystemState.ON + ) diff --git a/homeassistant/components/zwave_js/translations/ar.json b/homeassistant/components/zwave_js/translations/ar.json new file mode 100644 index 00000000000..65eac260b9f --- /dev/null +++ b/homeassistant/components/zwave_js/translations/ar.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "on_supervisor": { + "title": "\u062d\u062f\u062f \u0637\u0631\u064a\u0642\u0629 \u0627\u0644\u0627\u062a\u0635\u0627\u0644" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/ca.json b/homeassistant/components/zwave_js/translations/ca.json index d487ea8b902..a758d81e553 100644 --- a/homeassistant/components/zwave_js/translations/ca.json +++ b/homeassistant/components/zwave_js/translations/ca.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Configura el valor del par\u00e0metre {subtype}", + "node_status": "Estat del node", + "value": "Valor actual d'un valor Z-Wave" + }, + "trigger_type": { + "event.notification.entry_control": "Ha enviat una notificaci\u00f3 de control d'entrada", + "event.notification.notification": "Ha enviat una notificaci\u00f3", + "event.value_notification.basic": "Esdeveniment CC b\u00e0sic a {subtype}", + "event.value_notification.central_scene": "Acci\u00f3 d'escena central a {subtype}", + "event.value_notification.scene_activation": "Activaci\u00f3 d'escena a {subtype}", + "state.node_status": "L'estat del node ha canviat" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "No s'ha pogut obtenir la informaci\u00f3 de descobriment del complement Z-Wave JS.", diff --git a/homeassistant/components/zwave_js/translations/de.json b/homeassistant/components/zwave_js/translations/de.json index a5c637b51fa..9b01865d3be 100644 --- a/homeassistant/components/zwave_js/translations/de.json +++ b/homeassistant/components/zwave_js/translations/de.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Wert des Konfigurationsparameters {subtype}", + "node_status": "Status des Knotens", + "value": "Aktueller Wert eines Z-Wave-Wertes" + }, + "trigger_type": { + "event.notification.entry_control": "Benachrichtigung zur Zugangskontrolle gesendet", + "event.notification.notification": "Benachrichtigung gesendet", + "event.value_notification.basic": "Grundlegendes CC-Ereignis auf {subtype}", + "event.value_notification.central_scene": "Zentrale Szenenaktion auf {subtype}", + "event.value_notification.scene_activation": "Szenenaktivierung auf {subtype}", + "state.node_status": "Knotenstatus ge\u00e4ndert" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Die Discovery-Informationen des Z-Wave JS-Add-On konnten nicht abgerufen werden.", @@ -101,5 +116,5 @@ } } }, - "title": "" + "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/en.json b/homeassistant/components/zwave_js/translations/en.json index 27cafb6af6e..b742a011d19 100644 --- a/homeassistant/components/zwave_js/translations/en.json +++ b/homeassistant/components/zwave_js/translations/en.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Config parameter {subtype} value", + "node_status": "Node status", + "value": "Current value of a Z-Wave Value" + }, + "trigger_type": { + "event.notification.entry_control": "Sent an Entry Control notification", + "event.notification.notification": "Sent a notification", + "event.value_notification.basic": "Basic CC event on {subtype}", + "event.value_notification.central_scene": "Central Scene action on {subtype}", + "event.value_notification.scene_activation": "Scene Activation on {subtype}", + "state.node_status": "Node status changed" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Failed to get Z-Wave JS add-on discovery info.", diff --git a/homeassistant/components/zwave_js/translations/es.json b/homeassistant/components/zwave_js/translations/es.json index 1e5b07ec171..99ffee8270d 100644 --- a/homeassistant/components/zwave_js/translations/es.json +++ b/homeassistant/components/zwave_js/translations/es.json @@ -51,5 +51,55 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Valor del par\u00e1metro de configuraci\u00f3n {subtype}", + "node_status": "Estado del nodo", + "value": "Valor actual de un valor Z-Wave" + }, + "trigger_type": { + "event.notification.entry_control": "Envi\u00f3 una notificaci\u00f3n de control de entrada", + "event.notification.notification": "Envi\u00f3 una notificaci\u00f3n", + "event.value_notification.basic": "Evento CC b\u00e1sico en {subtype}", + "event.value_notification.central_scene": "Acci\u00f3n de escena central en {subtype}", + "event.value_notification.scene_activation": "Activaci\u00f3n de escena en {subtype}", + "state.node_status": "El estado del nodo ha cambiado" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Fallo en la obtenci\u00f3n de la informaci\u00f3n de descubrimiento del complemento Z-Wave JS.", + "addon_info_failed": "Fallo en la obtenci\u00f3n de la informaci\u00f3n del complemento Z-Wave JS.", + "addon_install_failed": "No se ha podido instalar el complemento Z-Wave JS.", + "addon_set_config_failed": "Fallo en la configuraci\u00f3n de Z-Wave JS.", + "addon_start_failed": "No se ha podido iniciar el complemento Z-Wave JS.", + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se pudo conectar", + "different_device": "El dispositivo USB conectado no es el mismo que el configurado anteriormente para esta entrada de configuraci\u00f3n. Por favor, cree una nueva entrada de configuraci\u00f3n para el nuevo dispositivo." + }, + "error": { + "cannot_connect": "No se pudo conectar", + "invalid_ws_url": "URL de websocket no v\u00e1lida", + "unknown": "Error inesperado" + }, + "progress": { + "install_addon": "Por favor, espere mientras termina la instalaci\u00f3n del complemento Z-Wave JS. Esto puede tardar varios minutos.", + "start_addon": "Por favor, espere mientras se completa el inicio del complemento Z-Wave JS. Esto puede tardar algunos segundos." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emular el hardware", + "log_level": "Nivel de registro", + "network_key": "Clave de red", + "usb_path": "Ruta del dispositivo USB" + }, + "title": "Introduzca la configuraci\u00f3n del complemento Z-Wave JS" + }, + "install_addon": { + "title": "La instalaci\u00f3n del complemento Z-Wave JS ha comenzado" + } + } + }, "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/et.json b/homeassistant/components/zwave_js/translations/et.json index 434a39e61d7..522c145d6d5 100644 --- a/homeassistant/components/zwave_js/translations/et.json +++ b/homeassistant/components/zwave_js/translations/et.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Seadeparameeteri {subtype} v\u00e4\u00e4rtus", + "node_status": "S\u00f5lme olek", + "value": "Z-Wave Value praegune v\u00e4\u00e4rtus" + }, + "trigger_type": { + "event.notification.entry_control": "L\u00e4bip\u00e4\u00e4su kontrolli teavitus on saadetud", + "event.notification.notification": "Teavitus on saadetud", + "event.value_notification.basic": "CC p\u00f5his\u00fcndmus {subtype}", + "event.value_notification.central_scene": "Keskse stseeni tegevus {subtype}", + "event.value_notification.scene_activation": "Stseeni aktiveerimine saidil {subtype}", + "state.node_status": "S\u00f5lme olek muutus" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Z-Wave JS lisandmooduli tuvastusteabe hankimine nurjus.", diff --git a/homeassistant/components/zwave_js/translations/fr.json b/homeassistant/components/zwave_js/translations/fr.json index 33571f12d60..1e51e97044a 100644 --- a/homeassistant/components/zwave_js/translations/fr.json +++ b/homeassistant/components/zwave_js/translations/fr.json @@ -51,5 +51,70 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Valeur du param\u00e8tre de configuration {subtype}", + "node_status": "\u00c9tat du n\u0153ud", + "value": "Valeur actuelle d'une valeur Z-Wave" + }, + "trigger_type": { + "event.notification.entry_control": "Envoi d'une notification de contr\u00f4le d'entr\u00e9e", + "event.notification.notification": "Envoyer une notification", + "event.value_notification.basic": "\u00c9v\u00e9nement CC de base sur {subtype}", + "event.value_notification.central_scene": "Action de la sc\u00e8ne centrale sur {subtype}", + "event.value_notification.scene_activation": "Activation de la sc\u00e8ne sur {sous-type}", + "state.node_status": "Changement de statut du noeud" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "\u00c9chec de l'obtention des informations de d\u00e9couverte du module compl\u00e9mentaire Z-Wave JS.", + "addon_info_failed": "\u00c9chec de l'obtention des informations sur le module compl\u00e9mentaire Z-Wave JS.", + "addon_install_failed": "\u00c9chec de l'installation du module compl\u00e9mentaire Z-Wave JS.", + "addon_set_config_failed": "\u00c9chec de la d\u00e9finition de la configuration Z-Wave JS.", + "addon_start_failed": "\u00c9chec du d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS.", + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "\u00c9chec de connexion", + "different_device": "Le p\u00e9riph\u00e9rique USB connect\u00e9 n'est pas le m\u00eame que pr\u00e9c\u00e9demment configur\u00e9 pour cette entr\u00e9e de configuration. Veuillez plut\u00f4t cr\u00e9er une nouvelle entr\u00e9e de configuration pour le nouveau p\u00e9riph\u00e9rique." + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_ws_url": "URL websocket invalide", + "unknown": "Erreur inattendue" + }, + "progress": { + "install_addon": "Veuillez patienter pendant que l'installation du module compl\u00e9mentaire Z-Wave JS se termine. Cela peut prendre plusieurs minutes.", + "start_addon": "Veuillez patienter pendant que le d\u00e9marrage du module compl\u00e9mentaire Z-Wave JS se termine. Cela peut prendre quelques secondes." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "\u00c9muler le mat\u00e9riel", + "log_level": "Niveau du journal", + "network_key": "Cl\u00e9 r\u00e9seau", + "usb_path": "Chemin du p\u00e9riph\u00e9rique USB" + }, + "title": "Entrer dans la configuration du module compl\u00e9mentaire Z-Wave JS" + }, + "install_addon": { + "title": "L'installation du module compl\u00e9mentaire Z-Wave JS a commenc\u00e9" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor" + }, + "description": "Voulez-vous utiliser le module compl\u00e9mentaire Z-Wave JS Supervisor\u00a0?", + "title": "S\u00e9lectionner la m\u00e9thode de connexion" + }, + "start_addon": { + "title": "Le module compl\u00e9mentaire Z-Wave JS d\u00e9marre." + } + } + }, "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/he.json b/homeassistant/components/zwave_js/translations/he.json index 4dbfc33457f..7c1cab98854 100644 --- a/homeassistant/components/zwave_js/translations/he.json +++ b/homeassistant/components/zwave_js/translations/he.json @@ -28,5 +28,40 @@ "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d7\u05d9\u05d1\u05d5\u05e8" } } + }, + "device_automation": { + "condition_type": { + "node_status": "\u05de\u05e6\u05d1 \u05d4\u05e6\u05d5\u05de\u05ea" + } + }, + "options": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "configure_addon": { + "data": { + "log_level": "\u05e8\u05de\u05ea \u05d9\u05d5\u05de\u05df \u05e8\u05d9\u05e9\u05d5\u05dd", + "network_key": "\u05de\u05e4\u05ea\u05d7 \u05e8\u05e9\u05ea", + "usb_path": "\u05e0\u05ea\u05d9\u05d1 \u05d4\u05ea\u05e7\u05df USB" + } + }, + "manual": { + "data": { + "url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8" + } + }, + "on_supervisor": { + "data": { + "use_addon": "\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4\u05e8\u05d7\u05d1\u05d4 \u05de\u05e4\u05e7\u05d7 Z-Wave JS" + }, + "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05d1\u05d4\u05e8\u05d7\u05d1\u05d4 \u05de\u05e4\u05e7\u05d7 Z-Wave JS?" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/hu.json b/homeassistant/components/zwave_js/translations/hu.json index 484dbfa1824..74a8b9db316 100644 --- a/homeassistant/components/zwave_js/translations/hu.json +++ b/homeassistant/components/zwave_js/translations/hu.json @@ -1,24 +1,35 @@ { "config": { "abort": { + "addon_get_discovery_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 felfedez\u00e9si inform\u00e1ci\u00f3kat.", + "addon_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 inform\u00e1ci\u00f3it.", + "addon_install_failed": "Nem siker\u00fclt telep\u00edteni a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", + "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani a Z-Wave JS konfigur\u00e1ci\u00f3t.", "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A konfigur\u00e1ci\u00f3 m\u00e1r folyamatban van.", "cannot_connect": "Sikertelen csatlakoz\u00e1s" }, "error": { + "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt. Ellen\u0151rizze a konfigur\u00e1ci\u00f3t.", "cannot_connect": "Sikertelen csatlakoz\u00e1s", "invalid_ws_url": "\u00c9rv\u00e9nytelen websocket URL", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "progress": { + "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", "start_addon": "V\u00e1rj am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny elindul. Ez eltarthat n\u00e9h\u00e1ny m\u00e1sodpercig." }, "step": { "configure_addon": { "data": { + "network_key": "H\u00e1l\u00f3zati kulcs", "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat" - } + }, + "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + }, + "hassio_confirm": { + "title": "\u00c1ll\u00edtsa be a Z-Wave JS integr\u00e1ci\u00f3t a Z-Wave JS kieg\u00e9sz\u00edt\u0151vel" }, "install_addon": { "title": "Elkezd\u0151d\u00f6tt a Z-Wave JS b\u0151v\u00edtm\u00e9ny telep\u00edt\u00e9se" @@ -40,16 +51,68 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Konfigur\u00e1lja a(z) {subtype} param\u00e9ter \u00e9rt\u00e9k\u00e9t", + "node_status": "Csom\u00f3pont \u00e1llapota", + "value": "A Z-Wave \u00e9rt\u00e9k aktu\u00e1lis \u00e9rt\u00e9ke" + }, + "trigger_type": { + "event.notification.entry_control": "Bel\u00e9p\u00e9s-ellen\u0151rz\u00e9si \u00e9rtes\u00edt\u00e9st k\u00fcld\u00f6tt", + "event.notification.notification": "\u00c9rtes\u00edt\u00e9s elk\u00fcldve", + "event.value_notification.basic": "Alapvet\u0151 CC esem\u00e9ny a(z) {subtype}", + "event.value_notification.central_scene": "K\u00f6zponti jelenet m\u0171velet {subtype}", + "event.value_notification.scene_activation": "Jelenetaktiv\u00e1l\u00e1s {subtype}", + "state.node_status": "A csom\u00f3pont \u00e1llapota megv\u00e1ltozott" + } + }, "options": { + "abort": { + "addon_get_discovery_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 felfedez\u00e9si inform\u00e1ci\u00f3kat.", + "addon_info_failed": "Nem siker\u00fclt megszerezni a Z-Wave JS kieg\u00e9sz\u00edt\u0151 inform\u00e1ci\u00f3it.", + "addon_install_failed": "Nem siker\u00fclt telep\u00edteni a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", + "addon_set_config_failed": "Nem siker\u00fclt be\u00e1ll\u00edtani a Z-Wave JS konfigur\u00e1ci\u00f3t.", + "addon_start_failed": "Nem siker\u00fclt elind\u00edtani a Z-Wave JS b\u0151v\u00edtm\u00e9nyt.", + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "different_device": "A csatlakoztatott USB-eszk\u00f6z nem ugyanaz, mint amelyet kor\u00e1bban ehhez a konfigur\u00e1ci\u00f3s bejegyz\u00e9shez konfigur\u00e1ltak. K\u00e9rj\u00fck, ink\u00e1bb hozzon l\u00e9tre egy \u00faj konfigur\u00e1ci\u00f3s bejegyz\u00e9st az \u00faj eszk\u00f6zh\u00f6z." + }, + "error": { + "cannot_connect": "Nem siker\u00fclt csatlakozni", + "invalid_ws_url": "\u00c9rv\u00e9nytelen websocket URL", + "unknown": "V\u00e1ratlan hiba" + }, + "progress": { + "install_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se befejez\u0151dik. Ez t\u00f6bb percig is eltarthat.", + "start_addon": "V\u00e1rjon, am\u00edg a Z-Wave JS b\u0151v\u00edtm\u00e9ny elindul. Ez eltarthat n\u00e9h\u00e1ny m\u00e1sodpercig." + }, "step": { "configure_addon": { "data": { + "emulate_hardware": "Hardver emul\u00e1ci\u00f3", "log_level": "Napl\u00f3szint", - "network_key": "H\u00e1l\u00f3zati kulcs" + "network_key": "H\u00e1l\u00f3zati kulcs", + "usb_path": "USB eszk\u00f6z \u00fatvonala" + }, + "title": "Adja meg a Z-Wave JS kieg\u00e9sz\u00edt\u0151 konfigur\u00e1ci\u00f3j\u00e1t" + }, + "install_addon": { + "title": "Elkezd\u0151d\u00f6tt a Z-Wave JS kieg\u00e9sz\u00edt\u0151 telep\u00edt\u00e9se" + }, + "manual": { + "data": { + "url": "URL" } }, "on_supervisor": { + "data": { + "use_addon": "Haszn\u00e1lja a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt" + }, + "description": "Szeretn\u00e9 haszn\u00e1lni a Z-Wave JS Supervisor b\u0151v\u00edtm\u00e9nyt?", "title": "V\u00e1laszd ki a csatlakoz\u00e1si m\u00f3dot" + }, + "start_addon": { + "title": "Indul a Z-Wave JS b\u0151v\u00edtm\u00e9ny." } } }, diff --git a/homeassistant/components/zwave_js/translations/id.json b/homeassistant/components/zwave_js/translations/id.json index 046cdd59485..61ea6762c7d 100644 --- a/homeassistant/components/zwave_js/translations/id.json +++ b/homeassistant/components/zwave_js/translations/id.json @@ -51,5 +51,55 @@ } } }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Gagal mendapatkan info penemuan add-on Z-Wave JS.", + "addon_info_failed": "Gagal mendapatkan info add-on Z-Wave JS.", + "addon_install_failed": "Gagal menginstal add-on Z-Wave JS.", + "addon_set_config_failed": "Gagal menyetel konfigurasi Z-Wave JS.", + "addon_start_failed": "Gagal memulai add-on Z-Wave JS.", + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Gagal terhubung", + "different_device": "Perangkat USB yang terhubung tidak sama dengan yang dikonfigurasi sebelumnya untuk entri konfigurasi ini. Buat entri konfigurasi baru untuk perangkat baru." + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_ws_url": "URL websocket tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "progress": { + "install_addon": "Harap tunggu hingga penginstalan add-on Z-Wave JS selesai. Ini bisa memakan waktu beberapa saat.", + "start_addon": "Harap tunggu hingga add-on Z-Wave JS selesai. Ini mungkin perlu waktu beberapa saat." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emulasikan Perangkat Keras", + "log_level": "Tingkat log", + "network_key": "Kunci Jaringan", + "usb_path": "Jalur Perangkat USB" + }, + "title": "Masukkan konfigurasi add-on Z-Wave JS" + }, + "install_addon": { + "title": "Instalasi add-on Z-Wave JS telah dimulai" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "Gunakan add-on Supervisor Z-Wave JS" + }, + "description": "Ingin menggunakan add-on Supervisor Z-Wave JS?", + "title": "Pilih metode koneksi" + }, + "start_addon": { + "title": "Add-on Z-Wave JS sedang dimulai." + } + } + }, "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/it.json b/homeassistant/components/zwave_js/translations/it.json index 71832e4882b..7c79cb304ef 100644 --- a/homeassistant/components/zwave_js/translations/it.json +++ b/homeassistant/components/zwave_js/translations/it.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Valore del parametro di configurazione {subtype}", + "node_status": "Stato del nodo", + "value": "Valore corrente di un valore Z-Wave" + }, + "trigger_type": { + "event.notification.entry_control": "Inviata una notifica di controllo delle entrate", + "event.notification.notification": "Inviata una notifica", + "event.value_notification.basic": "Evento CC di base su {subtype}", + "event.value_notification.central_scene": "Azione della scena centrale su {subtype}", + "event.value_notification.scene_activation": "Attivazione scena su {subtype}", + "state.node_status": "Lo stato del nodo \u00e8 cambiato" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Impossibile ottenere le informazioni sul rilevamento del componente aggiuntivo Z-Wave JS.", diff --git a/homeassistant/components/zwave_js/translations/nl.json b/homeassistant/components/zwave_js/translations/nl.json index 1e37b617c6d..3696380b43a 100644 --- a/homeassistant/components/zwave_js/translations/nl.json +++ b/homeassistant/components/zwave_js/translations/nl.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Config parameter {subtype} waarde", + "node_status": "Knooppuntstatus", + "value": "Huidige waarde van een Z-Wave-waarde" + }, + "trigger_type": { + "event.notification.entry_control": "Stuur een Entry Control melding", + "event.notification.notification": "Stuur een notificatie", + "event.value_notification.basic": "Basis CC-evenement op {subtype}", + "event.value_notification.central_scene": "Centrale Sc\u00e8ne actie op {subtype}", + "event.value_notification.scene_activation": "Sc\u00e8ne-activering op {subtype}", + "state.node_status": "Knooppuntstatus gewijzigd" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "Ophalen van ontdekkingsinformatie voor Z-Wave JS-add-on is mislukt.", @@ -59,7 +74,8 @@ "addon_set_config_failed": "Instellen van de Z-Wave JS configuratie is mislukt.", "addon_start_failed": "Kan de Z-Wave JS add-on niet starten.", "already_configured": "Apparaat is al geconfigureerd", - "cannot_connect": "Kan geen verbinding maken" + "cannot_connect": "Kan geen verbinding maken", + "different_device": "Het aangesloten USB-apparaat is niet hetzelfde als eerder geconfigureerd voor dit configuratie-item. Maak in plaats daarvan een nieuw configuratie-item voor het nieuwe apparaat." }, "error": { "cannot_connect": "Kan geen verbinding maken", diff --git a/homeassistant/components/zwave_js/translations/pl.json b/homeassistant/components/zwave_js/translations/pl.json index b729e8db3da..cc000691d25 100644 --- a/homeassistant/components/zwave_js/translations/pl.json +++ b/homeassistant/components/zwave_js/translations/pl.json @@ -51,5 +51,70 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "Warto\u015b\u0107 parametru jest {subtype}", + "node_status": "Stan w\u0119z\u0142a", + "value": "Aktualna warto\u015b\u0107 warto\u015bci Z-Wave" + }, + "trigger_type": { + "event.notification.entry_control": "Wys\u0142ano powiadomienie kontroli wpisu", + "event.notification.notification": "Wys\u0142ano powiadomienie", + "event.value_notification.basic": "Podstawowe wydarzenie CC na {subtype}", + "event.value_notification.central_scene": "Akcja sceny centralnej na {subtype}", + "event.value_notification.scene_activation": "Aktywacja sceny na {subtype}", + "state.node_status": "Zmieni\u0142 si\u0119 stan w\u0119z\u0142a" + } + }, + "options": { + "abort": { + "addon_get_discovery_info_failed": "Nie uda\u0142o si\u0119 uzyska\u0107 informacji wykrywania dodatku Z-Wave JS", + "addon_info_failed": "Nie uda\u0142o si\u0119 uzyska\u0107 informacji o dodatku Z-Wave JS", + "addon_install_failed": "Nie uda\u0142o si\u0119 zainstalowa\u0107 dodatku Z-Wave JS", + "addon_set_config_failed": "Nie uda\u0142o si\u0119 skonfigurowa\u0107 Z-Wave JS", + "addon_start_failed": "Nie uda\u0142o si\u0119 uruchomi\u0107 dodatku Z-Wave JS.", + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "different_device": "Pod\u0142\u0105czone urz\u0105dzenie USB nie jest takie samo, jak wcze\u015bniej skonfigurowane dla tego wpisu konfiguracyjnego. Zamiast tego, utw\u00f3rz nowy wpis konfiguracyjny dla nowego urz\u0105dzenia." + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_ws_url": "Nieprawid\u0142owy URL websocket", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "progress": { + "install_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 instalacja dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 kilka minut.", + "start_addon": "Poczekaj, a\u017c zako\u0144czy si\u0119 uruchamianie dodatku Z-Wave JS. Mo\u017ce to zaj\u0105\u0107 chwil\u0119." + }, + "step": { + "configure_addon": { + "data": { + "emulate_hardware": "Emulacja sprz\u0119tu", + "log_level": "Poziom loga", + "network_key": "Klucz sieci", + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "title": "Wprowad\u017a konfiguracj\u0119 dodatku Z-Wave JS" + }, + "install_addon": { + "title": "Rozpocz\u0119\u0142a si\u0119 instalacja dodatku Z-Wave JS" + }, + "manual": { + "data": { + "url": "URL" + } + }, + "on_supervisor": { + "data": { + "use_addon": "U\u017cyj dodatku Z-Wave JS Supervisor" + }, + "description": "Czy chcesz skorzysta\u0107 z dodatku Z-Wave JS Supervisor?", + "title": "Wybierz metod\u0119 po\u0142\u0105czenia" + }, + "start_addon": { + "title": "Dodatek Z-Wave JS uruchamia si\u0119..." + } + } + }, "title": "Z-Wave JS" } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/ru.json b/homeassistant/components/zwave_js/translations/ru.json index 64c8101740b..03529769828 100644 --- a/homeassistant/components/zwave_js/translations/ru.json +++ b/homeassistant/components/zwave_js/translations/ru.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {subtype}", + "node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430", + "value": "\u0422\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 Z-Wave Value" + }, + "trigger_type": { + "event.notification.entry_control": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043e \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u043e \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435 \u0432\u0445\u043e\u0434\u0430", + "event.notification.notification": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043e \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435", + "event.value_notification.basic": "\u0411\u0430\u0437\u043e\u0432\u043e\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 CC \u043d\u0430 {subtype}", + "event.value_notification.central_scene": "\u0414\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \"\u0426\u0435\u043d\u0442\u0440\u0430\u043b\u044c\u043d\u0430\u044f \u0441\u0446\u0435\u043d\u0430\" \u043d\u0430 {subtype}", + "event.value_notification.scene_activation": "\u0410\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u044f \u0441\u0446\u0435\u043d\u044b \u043d\u0430 {subtype}", + "state.node_status": "\u0421\u0442\u0430\u0442\u0443\u0441 \u0443\u0437\u043b\u0430 \u0438\u0437\u043c\u0435\u043d\u0435\u043d" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0431 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0438 \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f Z-Wave JS.", diff --git a/homeassistant/components/zwave_js/translations/zh-Hant.json b/homeassistant/components/zwave_js/translations/zh-Hant.json index 3e43c500c39..8b1c4caff1f 100644 --- a/homeassistant/components/zwave_js/translations/zh-Hant.json +++ b/homeassistant/components/zwave_js/translations/zh-Hant.json @@ -51,6 +51,21 @@ } } }, + "device_automation": { + "condition_type": { + "config_parameter": "\u8a2d\u5b9a\u53c3\u6578 {subtype} \u6578\u503c", + "node_status": "\u7bc0\u9ede\u72c0\u614b", + "value": "Z-Wave \u76ee\u524d\u503c" + }, + "trigger_type": { + "event.notification.entry_control": "\u50b3\u9001\u5be6\u9ad4\u63a7\u5236\u901a\u77e5", + "event.notification.notification": "\u50b3\u9001\u901a\u77e5", + "event.value_notification.basic": "{subtype} \u4e0a\u57fa\u672c CC \u4e8b\u4ef6", + "event.value_notification.central_scene": "{subtype} \u4e0a\u6838\u5fc3\u5834\u666f\u52d5\u4f5c", + "event.value_notification.scene_activation": "{subtype} \u4e0a\u5834\u666f\u5df2\u555f\u52d5", + "state.node_status": "\u7bc0\u9ede\u72c0\u614b\u5df2\u6539\u8b8a" + } + }, "options": { "abort": { "addon_get_discovery_info_failed": "\u53d6\u5f97 Z-Wave JS \u9644\u52a0\u5143\u4ef6\u63a2\u7d22\u8cc7\u8a0a\u5931\u6557\u3002", diff --git a/homeassistant/config.py b/homeassistant/config.py index c5f29f3c3c1..e7b6e04e8cf 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_ALLOWLIST_EXTERNAL_URLS, CONF_AUTH_MFA_MODULES, CONF_AUTH_PROVIDERS, + CONF_CURRENCY, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, @@ -238,6 +239,7 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend( # pylint: disable=no-value-for-parameter vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()), vol.Optional(CONF_LEGACY_TEMPLATES): cv.boolean, + vol.Optional(CONF_CURRENCY): cv.currency, } ) @@ -288,30 +290,30 @@ def _write_default_config(config_dir: str) -> bool: # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. try: - with open(config_path, "wt") as config_file: + with open(config_path, "wt", encoding="utf8") as config_file: config_file.write(DEFAULT_CONFIG) if not os.path.isfile(secret_path): - with open(secret_path, "wt") as secret_file: + with open(secret_path, "wt", encoding="utf8") as secret_file: secret_file.write(DEFAULT_SECRETS) - with open(version_path, "wt") as version_file: + with open(version_path, "wt", encoding="utf8") as version_file: version_file.write(__version__) if not os.path.isfile(group_yaml_path): - with open(group_yaml_path, "wt"): + with open(group_yaml_path, "wt", encoding="utf8"): pass if not os.path.isfile(automation_yaml_path): - with open(automation_yaml_path, "wt") as automation_file: + with open(automation_yaml_path, "wt", encoding="utf8") as automation_file: automation_file.write("[]") if not os.path.isfile(script_yaml_path): - with open(script_yaml_path, "wt"): + with open(script_yaml_path, "wt", encoding="utf8"): pass if not os.path.isfile(scene_yaml_path): - with open(scene_yaml_path, "wt"): + with open(scene_yaml_path, "wt", encoding="utf8"): pass return True @@ -377,7 +379,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: version_path = hass.config.path(VERSION_FILE) try: - with open(version_path) as inp: + with open(version_path, encoding="utf8") as inp: conf_version = inp.readline().strip() except FileNotFoundError: # Last version to not have this file @@ -421,7 +423,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: if os.path.isdir(lib_path): shutil.rmtree(lib_path) - with open(version_path, "wt") as outp: + with open(version_path, "wt", encoding="utf8") as outp: outp.write(__version__) @@ -511,7 +513,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non if any( k in config - for k in [ + for k in ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, @@ -520,7 +522,8 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non CONF_UNIT_SYSTEM, CONF_EXTERNAL_URL, CONF_INTERNAL_URL, - ] + CONF_CURRENCY, + ) ): hac.config_source = SOURCE_YAML @@ -533,6 +536,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: dict) -> Non (CONF_EXTERNAL_URL, "external_url"), (CONF_MEDIA_DIRS, "media_dirs"), (CONF_LEGACY_TEMPLATES, "legacy_templates"), + (CONF_CURRENCY, "currency"), ): if key in config: setattr(hac, attr, config[key]) @@ -899,7 +903,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: This method is a coroutine. """ # pylint: disable=import-outside-toplevel - import homeassistant.helpers.check_config as check_config + from homeassistant.helpers import check_config res = await check_config.async_check_ha_config_file(hass) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2bb8c4f3e29..ec074f81b95 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -850,7 +850,7 @@ class ConfigEntries: async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" await asyncio.gather( - *[entry.async_shutdown() for entry in self._entries.values()] + *(entry.async_shutdown() for entry in self._entries.values()) ) await self.flow.async_shutdown() @@ -1082,10 +1082,10 @@ class ConfigEntries: """Forward the unloading of an entry to platforms.""" return all( await asyncio.gather( - *[ + *( self.async_forward_entry_unload(entry, platform) for platform in platforms - ] + ) ) ) @@ -1506,7 +1506,7 @@ class EntityRegistryDisabledHandler: ) await asyncio.gather( - *[self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload] + *(self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload) ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9ad1ceb8a15..b95825ae39e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import Final MAJOR_VERSION: Final = 2021 -MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 8 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) @@ -266,6 +266,7 @@ STATE_ALARM_DISARMED: Final = "disarmed" STATE_ALARM_ARMED_HOME: Final = "armed_home" STATE_ALARM_ARMED_AWAY: Final = "armed_away" STATE_ALARM_ARMED_NIGHT: Final = "armed_night" +STATE_ALARM_ARMED_VACATION: Final = "armed_vacation" STATE_ALARM_ARMED_CUSTOM_BYPASS: Final = "armed_custom_bypass" STATE_ALARM_PENDING: Final = "pending" STATE_ALARM_ARMING: Final = "arming" @@ -273,6 +274,9 @@ STATE_ALARM_DISARMING: Final = "disarming" STATE_ALARM_TRIGGERED: Final = "triggered" STATE_LOCKED: Final = "locked" STATE_UNLOCKED: Final = "unlocked" +STATE_LOCKING: Final = "locking" +STATE_UNLOCKING: Final = "unlocking" +STATE_JAMMED: Final = "jammed" STATE_UNAVAILABLE: Final = "unavailable" STATE_OK: Final = "ok" STATE_PROBLEM: Final = "problem" @@ -392,21 +396,24 @@ ATTR_DEVICE_CLASS: Final = "device_class" # Temperature attribute ATTR_TEMPERATURE: Final = "temperature" + # #### UNITS OF MEASUREMENT #### # Power units POWER_WATT: Final = "W" POWER_KILO_WATT: Final = "kW" - -# Voltage units -VOLT: Final = "V" +POWER_VOLT_AMPERE: Final = "VA" # Energy units ENERGY_WATT_HOUR: Final = "Wh" ENERGY_KILO_WATT_HOUR: Final = "kWh" -# Electrical units -ELECTRICAL_CURRENT_AMPERE: Final = "A" -ELECTRICAL_VOLT_AMPERE: Final = "VA" +# Electric_current units +ELECTRIC_CURRENT_MILLIAMPERE: Final = "mA" +ELECTRIC_CURRENT_AMPERE: Final = "A" + +# Electric_potential units +ELECTRIC_POTENTIAL_MILLIVOLT: Final = "mV" +ELECTRIC_POTENTIAL_VOLT: Final = "V" # Degree units DEGREE: Final = "°" @@ -445,6 +452,7 @@ LENGTH_MILES: Final = "mi" # Frequency units FREQUENCY_HERTZ: Final = "Hz" +FREQUENCY_MEGAHERTZ: Final = "MHz" FREQUENCY_GIGAHERTZ: Final = "GHz" # Pressure units @@ -455,6 +463,10 @@ PRESSURE_MBAR: Final = "mbar" PRESSURE_INHG: Final = "inHg" PRESSURE_PSI: Final = "psi" +# Sound pressure units +SOUND_PRESSURE_DB: Final = "dB" +SOUND_PRESSURE_WEIGHTED_DBA: Final = "dBa" + # Volume units VOLUME_LITERS: Final = "L" VOLUME_MILLILITERS: Final = "mL" @@ -498,6 +510,8 @@ IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT: Final = "BTU/(h×ft²)" # Precipitation units PRECIPITATION_MILLIMETERS_PER_HOUR: Final = "mm/h" +PRECIPITATION_INCHES: Final = "in" +PRECIPITATION_INCHES_PER_HOUR: Final = "in/h" # Concentration units CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" @@ -541,6 +555,8 @@ DATA_PEBIBYTES: Final = "PiB" DATA_EXBIBYTES: Final = "EiB" DATA_ZEBIBYTES: Final = "ZiB" DATA_YOBIBYTES: Final = "YiB" + +# Data_rate units DATA_RATE_BITS_PER_SECOND: Final = "bit/s" DATA_RATE_KILOBITS_PER_SECOND: Final = "kbit/s" DATA_RATE_MEGABITS_PER_SECOND: Final = "Mbit/s" @@ -553,6 +569,7 @@ DATA_RATE_KIBIBYTES_PER_SECOND: Final = "KiB/s" DATA_RATE_MEBIBYTES_PER_SECOND: Final = "MiB/s" DATA_RATE_GIBIBYTES_PER_SECOND: Final = "GiB/s" + # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP: Final = "stop" SERVICE_HOMEASSISTANT_RESTART: Final = "restart" @@ -580,6 +597,7 @@ SERVICE_ALARM_DISARM: Final = "alarm_disarm" SERVICE_ALARM_ARM_HOME: Final = "alarm_arm_home" SERVICE_ALARM_ARM_AWAY: Final = "alarm_arm_away" SERVICE_ALARM_ARM_NIGHT: Final = "alarm_arm_night" +SERVICE_ALARM_ARM_VACATION: Final = "alarm_arm_vacation" SERVICE_ALARM_ARM_CUSTOM_BYPASS: Final = "alarm_arm_custom_bypass" SERVICE_ALARM_TRIGGER: Final = "alarm_trigger" diff --git a/homeassistant/core.py b/homeassistant/core.py index b9bf97e7e6c..e2418321592 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -201,7 +201,7 @@ class CoreState(enum.Enum): final_write = "FINAL_WRITE" stopped = "STOPPED" - def __str__(self) -> str: # pylint: disable=invalid-str-returned + def __str__(self) -> str: """Return the event.""" return self.value @@ -382,14 +382,14 @@ class HomeAssistant: self.loop.call_soon_threadsafe(self.async_create_task, target) @callback - def async_create_task(self, target: Awaitable) -> asyncio.tasks.Task: + def async_create_task(self, target: Awaitable) -> asyncio.Task: """Create a task from within the eventloop. This method must be run in the event loop. target: target to call. """ - task: asyncio.tasks.Task = self.loop.create_task(target) + task: asyncio.Task = self.loop.create_task(target) if self._track_task: self._pending_tasks.append(task) @@ -593,7 +593,7 @@ class EventOrigin(enum.Enum): local = "LOCAL" remote = "REMOTE" - def __str__(self) -> str: # pylint: disable=invalid-str-returned + def __str__(self) -> str: """Return the event.""" return self.value @@ -669,7 +669,7 @@ class EventBus: This method must be run in the event loop. """ - return {key: len(self._listeners[key]) for key in self._listeners} + return {key: len(listeners) for key, listeners in self._listeners.items()} @property def listeners(self) -> dict[str, int]: @@ -1298,7 +1298,7 @@ class ServiceRegistry: This method must be run in the event loop. """ - return {domain: self._services[domain].copy() for domain in self._services} + return {domain: service.copy() for domain, service in self._services.items()} def has_service(self, domain: str, service: str) -> bool: """Test if specified service exists. @@ -1545,6 +1545,7 @@ class Config: self.units: UnitSystem = METRIC_SYSTEM self.internal_url: str | None = None self.external_url: str | None = None + self.currency: str = "EUR" self.config_source: str = "default" @@ -1650,6 +1651,7 @@ class Config: "state": self.hass.state.value, "external_url": self.external_url, "internal_url": self.internal_url, + "currency": self.currency, } def set_time_zone(self, time_zone_str: str) -> None: @@ -1676,6 +1678,7 @@ class Config: # pylint: disable=dangerous-default-value # _UNDEFs not modified external_url: str | dict | None = _UNDEF, internal_url: str | dict | None = _UNDEF, + currency: str | None = None, ) -> None: """Update the configuration from a dictionary.""" self.config_source = source @@ -1698,6 +1701,8 @@ class Config: self.external_url = cast(Optional[str], external_url) if internal_url is not _UNDEF: self.internal_url = cast(Optional[str], internal_url) + if currency is not None: + self.currency = currency async def async_update(self, **kwargs: Any) -> None: """Update the configuration from a dictionary.""" @@ -1723,6 +1728,7 @@ class Config: time_zone=data.get("time_zone"), external_url=data.get("external_url", _UNDEF), internal_url=data.get("internal_url", _UNDEF), + currency=data.get("currency"), ) async def async_store(self) -> None: @@ -1736,6 +1742,7 @@ class Config: "time_zone": self.time_zone, "external_url": self.external_url, "internal_url": self.internal_url, + "currency": self.currency, } store = self.hass.helpers.storage.Store( diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e71503ce5fc..4cb9e2e3c4b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "accuweather", "acmeda", + "adax", "adguard", "advantage_air", "aemet", @@ -45,6 +46,7 @@ FLOWS = [ "cert_expiry", "climacell", "cloudflare", + "co2signal", "coinbase", "control4", "coolmaster", @@ -75,6 +77,7 @@ FLOWS = [ "faa_delays", "fireservicerota", "flick_electric", + "flipr", "flo", "flume", "flunearyou", @@ -87,7 +90,6 @@ FLOWS = [ "fritzbox", "fritzbox_callmonitor", "garages_amsterdam", - "garmin_connect", "gdacs", "geofency", "geonetnz_quakes", @@ -113,6 +115,7 @@ FLOWS = [ "homekit", "homekit_controller", "homematicip_cloud", + "honeywell", "huawei_lte", "hue", "huisbaasje", @@ -175,6 +178,7 @@ FLOWS = [ "nest", "netatmo", "nexia", + "nfandroidtv", "nightscout", "notion", "nuheat", @@ -205,11 +209,13 @@ FLOWS = [ "powerwall", "profiler", "progettihwsw", + "prosegur", "ps4", "pvpc_hourly_pricing", "rachio", "rainmachine", "recollect_waste", + "renault", "rfxtrx", "ring", "risco", @@ -251,6 +257,7 @@ FLOWS = [ "srp_energy", "starline", "subaru", + "switcher_kis", "syncthing", "syncthru", "synology_dsm", @@ -293,8 +300,10 @@ FLOWS = [ "xbox", "xiaomi_aqara", "xiaomi_miio", + "yale_smart_alarm", "yamaha_musiccast", "yeelight", + "youless", "zerproc", "zha", "zwave", diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 6d9815e54d5..f1ea4800c16 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -139,15 +139,15 @@ class ObservableCollection(ABC): async def notify_changes(self, change_sets: Iterable[CollectionChangeSet]) -> None: """Notify listeners of a change.""" await asyncio.gather( - *[ + *( listener(change_set.change_type, change_set.item_id, change_set.item) for listener in self.listeners for change_set in change_sets - ], - *[ + ), + *( change_set_listener(change_sets) for change_set_listener in self.change_set_listeners - ], + ), ) @@ -368,10 +368,10 @@ def sync_entity_lifecycle( new_entities = [ entity for entity in await asyncio.gather( - *[ + *( _func_map[change_set.change_type](change_set) for change_set in grouped - ] + ) ) if entity is not None ] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e195c1ded31..66d1c01d6d3 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1269,3 +1269,167 @@ ACTION_TYPE_SCHEMAS: dict[str, Callable[[Any], dict]] = { SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA, SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA, } + + +# Validate currencies adopted by countries +currency = vol.In( + { + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BRL", + "BSD", + "BTN", + "BWP", + "BYR", + "BZD", + "CAD", + "CDF", + "CHF", + "CLP", + "CNY", + "COP", + "CRC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HRK", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LTL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRO", + "MUR", + "MVR", + "MWK", + "MXN", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLL", + "SOS", + "SRD", + "SSP", + "STD", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "UYU", + "UZS", + "VEF", + "VND", + "VUV", + "WST", + "XAF", + "XCD", + "XOF", + "XPF", + "YER", + "ZAR", + "ZMK", + "ZWL", + }, + msg="invalid ISO 4217 formatted currency", +) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9f09bbbf642..b22b1740a4f 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -670,6 +670,7 @@ def async_config_entry_disabled_by_changed( the config entry is disabled, enable devices in the registry that are associated with a config entry when the config entry is enabled and the devices are marked DISABLED_CONFIG_ENTRY. + Only disable a device if all associated config entries are disabled. """ devices = async_entries_for_config_entry(registry, config_entry.entry_id) @@ -681,10 +682,20 @@ def async_config_entry_disabled_by_changed( registry.async_update_device(device.id, disabled_by=None) return + enabled_config_entries = { + entry.entry_id + for entry in registry.hass.config_entries.async_entries() + if not entry.disabled_by + } + for device in devices: if device.disabled: # Device already disabled, do not overwrite continue + if len(device.config_entries) > 1 and device.config_entries.intersection( + enabled_config_entries + ): + continue registry.async_update_device(device.id, disabled_by=DISABLED_CONFIG_ENTRY) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 187d53ea00b..6383de15b4a 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -3,7 +3,8 @@ from __future__ import annotations from abc import ABC import asyncio -from collections.abc import Awaitable, Iterable, Mapping +from collections.abc import Awaitable, Iterable, Mapping, MutableMapping +from dataclasses import dataclass from datetime import datetime, timedelta import functools as ft import logging @@ -178,6 +179,21 @@ class DeviceInfo(TypedDict, total=False): default_model: str +@dataclass +class EntityDescription: + """A class that describes Home Assistant entities.""" + + # This is the key identifier for this entity + key: str + + device_class: str | None = None + entity_registry_enabled_default: bool = True + force_update: bool = False + icon: str | None = None + name: str | None = None + unit_of_measurement: str | None = None + + class Entity(ABC): """An abstract class for Home Assistant entities.""" @@ -194,6 +210,9 @@ class Entity(ABC): # Owning platform instance. Will be set by EntityPlatform platform: EntityPlatform | None = None + # Entity description instance for this Entity + entity_description: EntityDescription + # If we reported if this entity was slow _slow_reported = False @@ -223,19 +242,19 @@ class Entity(ABC): _attr_assumed_state: bool = False _attr_available: bool = True _attr_context_recent_time: timedelta = timedelta(seconds=5) - _attr_device_class: str | None = None + _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_picture: str | None = None - _attr_entity_registry_enabled_default: bool = True - _attr_extra_state_attributes: Mapping[str, Any] | None = None - _attr_force_update: bool = False - _attr_icon: str | None = None - _attr_name: str | None = None + _attr_entity_registry_enabled_default: bool + _attr_extra_state_attributes: MutableMapping[str, Any] + _attr_force_update: bool + _attr_icon: str | None + _attr_name: str | None _attr_should_poll: bool = True _attr_state: StateType = STATE_UNKNOWN _attr_supported_features: int | None = None _attr_unique_id: str | None = None - _attr_unit_of_measurement: str | None = None + _attr_unit_of_measurement: str | None @property def should_poll(self) -> bool: @@ -253,7 +272,11 @@ class Entity(ABC): @property def name(self) -> str | None: """Return the name of the entity.""" - return self._attr_name + if hasattr(self, "_attr_name"): + return self._attr_name + if hasattr(self, "entity_description"): + return self.entity_description.name + return None @property def state(self) -> StateType: @@ -296,7 +319,9 @@ class Entity(ABC): Implemented by platform classes. Convention for attribute names is lowercase snake_case. """ - return self._attr_extra_state_attributes + if hasattr(self, "_attr_extra_state_attributes"): + return self._attr_extra_state_attributes + return None @property def device_info(self) -> DeviceInfo | None: @@ -309,17 +334,29 @@ class Entity(ABC): @property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" - return self._attr_device_class + if hasattr(self, "_attr_device_class"): + return self._attr_device_class + if hasattr(self, "entity_description"): + return self.entity_description.device_class + return None @property def unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" - return self._attr_unit_of_measurement + if hasattr(self, "_attr_unit_of_measurement"): + return self._attr_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.unit_of_measurement + return None @property def icon(self) -> str | None: """Return the icon to use in the frontend, if any.""" - return self._attr_icon + if hasattr(self, "_attr_icon"): + return self._attr_icon + if hasattr(self, "entity_description"): + return self.entity_description.icon + return None @property def entity_picture(self) -> str | None: @@ -343,7 +380,11 @@ class Entity(ABC): If True, a state change will be triggered anytime the state property is updated, not just when the value changes. """ - return self._attr_force_update + if hasattr(self, "_attr_force_update"): + return self._attr_force_update + if hasattr(self, "entity_description"): + return self.entity_description.force_update + return False @property def supported_features(self) -> int | None: @@ -358,7 +399,11 @@ class Entity(ABC): @property def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" - return self._attr_entity_registry_enabled_default + if hasattr(self, "_attr_entity_registry_enabled_default"): + return self._attr_entity_registry_enabled_default + if hasattr(self, "entity_description"): + return self.entity_description.entity_registry_enabled_default + return True # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they @@ -814,9 +859,15 @@ class Entity(ABC): self.parallel_updates.release() +@dataclass +class ToggleEntityDescription(EntityDescription): + """A class that describes toggle entities.""" + + class ToggleEntity(Entity): """An abstract class for entities that can be turned on and off.""" + entity_description: ToggleEntityDescription _attr_is_on: bool _attr_state: None = None diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 37c0a7620ab..7f329d02133 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -331,5 +331,5 @@ class EntityComponent: async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" await asyncio.gather( - *[platform.async_shutdown() for platform in chain(self._platforms.values())] + *(platform.async_shutdown() for platform in chain(self._platforms.values())) ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5436a01648e..778b7f3747d 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -8,9 +8,8 @@ from datetime import datetime, timedelta import logging from logging import Logger from types import ModuleType -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, Protocol -from typing_extensions import Protocol import voluptuous as vol from homeassistant import config_entries diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index fc9ef575c7d..69415030a87 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -160,8 +160,14 @@ class EntityRegistry: ) @callback - def async_get_device_class_lookup(self, domain_device_classes: set) -> dict: - """Return a lookup for the device class by domain.""" + def async_get_device_class_lookup( + self, domain_device_classes: set[tuple[str, str | None]] + ) -> dict: + """Return a lookup of entity ids for devices which have matching entities. + + Entities must match a set of (domain, device_class) tuples. + The result is indexed by device_id, then by the matching (domain, device_class) + """ lookup: dict[str, dict[tuple[Any, Any], str]] = {} for entity in self.entities.values(): if not entity.device_id: diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 85eebf05298..ab54d159f5e 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -977,10 +977,10 @@ class _TrackTemplateResultInfo: self._track_state_changes.async_update_listeners( _render_infos_to_track_states( [ - _suppress_domain_all_in_render_info(self._info[template]) + _suppress_domain_all_in_render_info(info) if self._rate_limit.async_has_timer(template) - else self._info[template] - for template in self._info + else info + for template, info in self._info.items() ] ) ) @@ -1440,7 +1440,9 @@ def async_track_time_change( track_time_change = threaded_listener_factory(async_track_time_change) -def process_state_match(parameter: None | str | Iterable[str]) -> Callable[[str], bool]: +def process_state_match( + parameter: None | str | Iterable[str], +) -> Callable[[str | None], bool]: """Convert parameter to function that matches input against parameter.""" if parameter is None or parameter == MATCH_ALL: return lambda _: True diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 156ceb8e612..e5e8ef4fd52 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -239,7 +239,7 @@ async def async_validate_actions_config( ) -> list[ConfigType]: """Validate a list of actions.""" return await asyncio.gather( - *[async_validate_action_config(hass, action) for action in actions] + *(async_validate_action_config(hass, action) for action in actions) ) @@ -504,7 +504,7 @@ class _ScriptRun: task.cancel() unsub() - async def _async_run_long_action(self, long_task: asyncio.tasks.Task) -> None: + async def _async_run_long_action(self, long_task: asyncio.Task) -> None: """Run a long task while monitoring for stop request.""" async def async_cancel_long_task() -> None: @@ -599,7 +599,7 @@ class _ScriptRun: """Fire an event.""" self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT])) event_data = {} - for conf in [CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE]: + for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE): if conf not in self._action: continue @@ -880,10 +880,10 @@ async def _async_stop_scripts_after_shutdown(hass, point_in_time): names = ", ".join([script["instance"].name for script in running_scripts]) _LOGGER.warning("Stopping scripts running too long after shutdown: %s", names) await asyncio.gather( - *[ + *( script["instance"].async_stop(update_state=False) for script in running_scripts - ] + ) ) @@ -902,7 +902,7 @@ async def _async_stop_scripts_at_shutdown(hass, event): names = ", ".join([script["instance"].name for script in running_scripts]) _LOGGER.debug("Stopping scripts running at shutdown: %s", names) await asyncio.gather( - *[script["instance"].async_stop() for script in running_scripts] + *(script["instance"].async_stop() for script in running_scripts) ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index ff037998f34..ed07c6bda63 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -227,7 +227,7 @@ def async_prepare_call_from_config( service_data = {} - for conf in [CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE]: + for conf in (CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE): if conf not in config: continue try: diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 6d6c912f8c9..766fa90af96 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,6 +1,7 @@ """Helper to gather system info.""" from __future__ import annotations +from getpass import getuser import os import platform from typing import Any @@ -22,6 +23,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: "virtualenv": is_virtual_env(), "python_version": platform.python_version(), "docker": False, + "user": getuser(), "arch": platform.machine(), "timezone": str(hass.config.time_zone), "os_name": platform.system(), @@ -37,7 +39,8 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # Determine installation type on current data if info_object["docker"]: - info_object["installation_type"] = "Home Assistant Container" + if info_object["user"] == "root": + info_object["installation_type"] = "Home Assistant Container" elif is_virtual_env(): info_object["installation_type"] = "Home Assistant Core" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index d991a0b58f2..66354aa7aa6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -43,7 +43,11 @@ from homeassistant.core import ( valid_entity_id, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import entity_registry, location as loc_helper +from homeassistant.helpers import ( + device_registry, + entity_registry, + location as loc_helper, +) from homeassistant.helpers.typing import TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import convert, dt as dt_util, location as loc_util @@ -902,13 +906,49 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: return sorted(found.values(), key=lambda a: a.entity_id) -def device_entities(hass: HomeAssistant, device_id: str) -> Iterable[str]: +def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]: """Get entity ids for entities tied to a device.""" entity_reg = entity_registry.async_get(hass) - entries = entity_registry.async_entries_for_device(entity_reg, device_id) + entries = entity_registry.async_entries_for_device(entity_reg, _device_id) return [entry.entity_id for entry in entries] +def device_id(hass: HomeAssistant, entity_id: str) -> str | None: + """Get a device ID from an entity ID.""" + if not isinstance(entity_id, str) or "." not in entity_id: + raise TemplateError(f"Must provide an entity ID, got {entity_id}") # type: ignore + entity_reg = entity_registry.async_get(hass) + entity = entity_reg.async_get(entity_id) + if entity is None: + return None + return entity.device_id + + +def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any: + """Get the device specific attribute.""" + device_reg = device_registry.async_get(hass) + if not isinstance(device_or_entity_id, str): + raise TemplateError("Must provide a device or entity ID") + device = None + if ( + "." in device_or_entity_id + and (_device_id := device_id(hass, device_or_entity_id)) is not None + ): + device = device_reg.async_get(_device_id) + elif "." not in device_or_entity_id: + device = device_reg.async_get(device_or_entity_id) + if device is None or not hasattr(device, attr_name): + return None + return getattr(device, attr_name) + + +def is_device_attr( + hass: HomeAssistant, device_or_entity_id: str, attr_name: str, attr_value: Any +) -> bool: + """Test if a device's attribute is a specific value.""" + return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value) + + def closest(hass, *args): """Find closest entity. @@ -1486,6 +1526,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["device_entities"] = hassfunction(device_entities) self.filters["device_entities"] = pass_context(self.globals["device_entities"]) + self.globals["device_attr"] = hassfunction(device_attr) + self.globals["is_device_attr"] = hassfunction(is_device_attr) + + self.globals["device_id"] = hassfunction(device_id) + self.filters["device_id"] = pass_context(self.globals["device_id"]) + if limited: # Only device_entities is available to limited templates, mark other # functions and filters as unsupported. @@ -1507,8 +1553,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): "states", "utcnow", "now", + "device_attr", + "is_device_attr", + "device_id", ] - hass_filters = ["closest", "expand"] + hass_filters = ["closest", "expand", "device_id"] for glob in hass_globals: self.globals[glob] = unsupported(glob) for filt in hass_filters: diff --git a/homeassistant/helpers/trace.py b/homeassistant/helpers/trace.py index 33fe76c9eab..e25cf814b2a 100644 --- a/homeassistant/helpers/trace.py +++ b/homeassistant/helpers/trace.py @@ -92,7 +92,7 @@ trace_path_stack_cv: ContextVar[list[str] | None] = ContextVar( # Copy of last variables variables_cv: ContextVar[Any | None] = ContextVar("variables_cv", default=None) # (domain, item_id) + Run ID -trace_id_cv: ContextVar[tuple[str, str] | None] = ContextVar( +trace_id_cv: ContextVar[tuple[tuple[str, str], str] | None] = ContextVar( "trace_id_cv", default=None ) # Reason for stopped script execution @@ -101,12 +101,12 @@ script_execution_cv: ContextVar[StopReason | None] = ContextVar( ) -def trace_id_set(trace_id: tuple[str, str]) -> None: +def trace_id_set(trace_id: tuple[tuple[str, str], str]) -> None: """Set id of the current trace.""" trace_id_cv.set(trace_id) -def trace_id_get() -> tuple[str, str] | None: +def trace_id_get() -> tuple[tuple[str, str], str] | None: """Get id if the current trace.""" return trace_id_cv.get() diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index ed9049a8a13..a77cf3c2227 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -155,7 +155,7 @@ async def async_get_component_strings( domains, await gather_with_concurrency( MAX_LOAD_CONCURRENTLY, - *[async_get_integration(hass, domain) for domain in domains], + *(async_get_integration(hass, domain) for domain in domains), ), ) ) @@ -234,10 +234,10 @@ class _TranslationCache: # Fetch the English resources, as a fallback for missing keys languages = [LOCALE_EN] if language == LOCALE_EN else [LOCALE_EN, language] for translation_strings in await asyncio.gather( - *[ + *( async_get_component_strings(self.hass, lang, components) for lang in languages - ] + ) ): self._build_category_cache(language, components, translation_strings) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 06bf5045c9f..a535db4bde2 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -534,7 +534,7 @@ async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration try: integration = await _async_get_integration(hass, domain) - except Exception: # pylint: disable=broad-except + except Exception: # Remove event from cache. cache.pop(domain) event.set() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ec9ab9d062e..7b9fe917279 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,5 @@ PyJWT==1.7.1 -PyNaCl==1.3.0 +PyNaCl==1.4.0 aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 @@ -17,8 +17,8 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210707.0 -httpx==0.18.0 +home-assistant-frontend==20210803.2 +httpx==0.18.2 ifaddr==0.1.7 jinja2==3.0.1 paho-mqtt==1.5.1 @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.32.1 +zeroconf==0.33.2 pycryptodome>=3.6.6 @@ -62,7 +62,6 @@ enum34==1000000000.0.0 typing==1000000000.0.0 uuid==1000000000.0.0 -# httpcore 0.13.4 breaks several integrations -# https://github.com/home-assistant/core/issues/51778 -httpcore==0.13.3 - +# Temporary constraint on pandas, to unblock 2021.7 releases +# until we have fixed the wheels builds for newer versions. +pandas==1.3.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 02187fe8f0e..67d0ede96bc 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -14,6 +14,7 @@ import homeassistant.util.package as pkg_util # mypy: disallow-any-generics +PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency DATA_PIP_LOCK = "pip_lock" DATA_PKG_CACHE = "pkg_cache" DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" @@ -118,10 +119,10 @@ async def _async_process_integration( return results = await asyncio.gather( - *[ + *( async_get_integration_with_requirements(hass, dep, done) for dep in deps_to_check - ], + ), return_exceptions=True, ) for result in results: @@ -169,6 +170,7 @@ def pip_kwargs(config_dir: str | None) -> dict[str, Any]: kwargs = { "constraints": os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE), "no_cache_dir": is_docker, + "timeout": PIP_TIMEOUT, } if "WHEELS_LINKS" in os.environ: kwargs["find_links"] = os.environ["WHEELS_LINKS"] diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 9b0c5282108..07bbaa22954 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -280,10 +280,10 @@ async def _async_setup_component( await hass.config_entries.flow.async_wait_init_flow_finish(domain) await asyncio.gather( - *[ + *( entry.async_setup(hass, integration=integration) for entry in hass.config_entries.async_entries(domain) - ] + ) ) hass.config.components.add(domain) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index f7f07434555..60f3e409f06 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -79,9 +79,9 @@ def sanitize_path(path: str) -> str: return path -def slugify(text: str, *, separator: str = "_") -> str: +def slugify(text: str | None, *, separator: str = "_") -> str: """Slugify a given text.""" - if text == "": + if text == "" or text is None: return "" slug = unicode_slug.slugify(text, separator=separator) return "unknown" if slug == "" else slug @@ -161,9 +161,6 @@ def get_random_string(length: int = 10) -> str: class OrderedEnum(enum.Enum): """Taken from Python 3.4.0 docs.""" - # https://github.com/PyCQA/pylint/issues/2306 - # pylint: disable=comparison-with-callable - def __ge__(self, other: ENUM_T) -> bool: """Return the greater than element.""" if self.__class__ is other.__class__: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index a467d544174..86308b48f7a 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -30,7 +30,7 @@ def fire_coroutine_threadsafe(coro: Coroutine, loop: AbstractEventLoop) -> None: raise RuntimeError("Cannot be called from within the event loop") if not coroutines.iscoroutine(coro): - raise TypeError("A coroutine object is required: %s" % coro) + raise TypeError(f"A coroutine object is required: {coro}") def callback() -> None: """Handle the firing of a coroutine.""" diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py index 592c7c3145e..6b21e9b4c47 100644 --- a/homeassistant/util/distance.py +++ b/homeassistant/util/distance.py @@ -17,7 +17,7 @@ from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, ) -VALID_UNITS = [ +VALID_UNITS: tuple[str, ...] = ( LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_FEET, @@ -26,7 +26,7 @@ VALID_UNITS = [ LENGTH_MILLIMETERS, LENGTH_INCHES, LENGTH_YARD, -] +) TO_METERS: dict[str, Callable[[float], float]] = { LENGTH_METERS: lambda meters: meters, diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index a9a6ca4e3a3..93737ce0c3d 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -13,9 +13,9 @@ import ciso8601 from homeassistant.const import MATCH_ALL if sys.version_info[:2] >= (3, 9): - import zoneinfo # pylint: disable=import-error + import zoneinfo else: - from backports import zoneinfo # pylint: disable=import-error + from backports import zoneinfo DATE_STR_FORMAT = "%Y-%m-%d" UTC = dt.timezone.utc diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index 2a3a4ff0922..abe8fedd21a 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -12,7 +12,10 @@ from typing import Any import aiohttp +from homeassistant.const import __version__ as HA_VERSION + WHOAMI_URL = "https://whoami.home-assistant.io/v1" +WHOAMI_URL_DEV = "https://whoami-v1-dev.home-assistant.workers.dev/v1" # Constants from https://github.com/maurycyp/vincenty # Earth ellipsoid according to WGS 84 @@ -32,6 +35,7 @@ LocationInfo = collections.namedtuple( [ "ip", "country_code", + "currency", "region_code", "region_name", "city", @@ -161,7 +165,9 @@ def vincenty( async def _get_whoami(session: aiohttp.ClientSession) -> dict[str, Any] | None: """Query whoami.home-assistant.io for location data.""" try: - resp = await session.get(WHOAMI_URL, timeout=30) + resp = await session.get( + WHOAMI_URL_DEV if HA_VERSION.endswith("0.dev0") else WHOAMI_URL, timeout=30 + ) except (aiohttp.ClientError, asyncio.TimeoutError): return None @@ -173,6 +179,7 @@ async def _get_whoami(session: aiohttp.ClientSession) -> dict[str, Any] | None: return { "ip": raw_info.get("ip"), "country_code": raw_info.get("country"), + "currency": raw_info.get("currency"), "region_code": raw_info.get("region_code"), "region_name": raw_info.get("region"), "city": raw_info.get("city"), diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 50d46b6c469..609d09e4f55 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -63,6 +63,7 @@ def install_package( target: str | None = None, constraints: str | None = None, find_links: str | None = None, + timeout: int | None = None, no_cache_dir: bool | None = False, ) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. @@ -73,6 +74,8 @@ def install_package( _LOGGER.info("Attempting install of %s", package) env = os.environ.copy() args = [sys.executable, "-m", "pip", "install", "--quiet", package] + if timeout: + args += ["--timeout", str(timeout)] if no_cache_dir: args.append("--no-cache-dir") if upgrade: diff --git a/homeassistant/util/percentage.py b/homeassistant/util/percentage.py index 42beeeb5523..260c4f374fe 100644 --- a/homeassistant/util/percentage.py +++ b/homeassistant/util/percentage.py @@ -1,8 +1,12 @@ """Percentage util functions.""" from __future__ import annotations +from typing import TypeVar -def ordered_list_item_to_percentage(ordered_list: list[str], item: str) -> int: +T = TypeVar("T") + + +def ordered_list_item_to_percentage(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" @@ -25,7 +29,7 @@ def ordered_list_item_to_percentage(ordered_list: list[str], item: str) -> int: return (list_position * 100) // list_len -def percentage_to_ordered_list_item(ordered_list: list[str], percentage: int) -> str: +def percentage_to_ordered_list_item(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/pressure.py b/homeassistant/util/pressure.py index 24ad3242921..188cf66491e 100644 --- a/homeassistant/util/pressure.py +++ b/homeassistant/util/pressure.py @@ -1,4 +1,6 @@ """Pressure util functions.""" +from __future__ import annotations + from numbers import Number from homeassistant.const import ( @@ -11,9 +13,15 @@ from homeassistant.const import ( UNIT_NOT_RECOGNIZED_TEMPLATE, ) -VALID_UNITS = [PRESSURE_PA, PRESSURE_HPA, PRESSURE_MBAR, PRESSURE_INHG, PRESSURE_PSI] +VALID_UNITS: tuple[str, ...] = ( + PRESSURE_PA, + PRESSURE_HPA, + PRESSURE_MBAR, + PRESSURE_INHG, + PRESSURE_PSI, +) -UNIT_CONVERSION = { +UNIT_CONVERSION: dict[str, float] = { PRESSURE_PA: 1, PRESSURE_HPA: 1 / 100, PRESSURE_MBAR: 1 / 100, diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py index b9f69b15578..8d813eaa5a4 100644 --- a/homeassistant/util/ruamel_yaml.py +++ b/homeassistant/util/ruamel_yaml.py @@ -47,7 +47,7 @@ def _include_yaml( """ if constructor.name is None: raise HomeAssistantError( - "YAML include error: filename not set for %s" % node.value + f"YAML include error: filename not set for {node.value}" ) fname = os.path.join(os.path.dirname(constructor.name), node.value) return load_yaml(fname, False) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index b5c8c38425a..bdd47112dde 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -36,13 +36,13 @@ from homeassistant.util import ( LENGTH_UNITS = distance_util.VALID_UNITS -MASS_UNITS = [MASS_POUNDS, MASS_OUNCES, MASS_KILOGRAMS, MASS_GRAMS] +MASS_UNITS: tuple[str, ...] = (MASS_POUNDS, MASS_OUNCES, MASS_KILOGRAMS, MASS_GRAMS) PRESSURE_UNITS = pressure_util.VALID_UNITS VOLUME_UNITS = volume_util.VALID_UNITS -TEMPERATURE_UNITS = [TEMP_FAHRENHEIT, TEMP_CELSIUS] +TEMPERATURE_UNITS: tuple[str, ...] = (TEMP_FAHRENHEIT, TEMP_CELSIUS) def is_valid_unit(unit: str, unit_type: str) -> bool: @@ -78,13 +78,13 @@ class UnitSystem: """Initialize the unit system object.""" errors: str = ", ".join( UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) - for unit, unit_type in [ + for unit, unit_type in ( (temperature, TEMPERATURE), (length, LENGTH), (volume, VOLUME), (mass, MASS), (pressure, PRESSURE), - ] + ) if not is_valid_unit(unit, unit_type) ) diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py index 5d94f848cd8..f4a02dbe82e 100644 --- a/homeassistant/util/volume.py +++ b/homeassistant/util/volume.py @@ -1,4 +1,6 @@ """Volume conversion util functions.""" +from __future__ import annotations + from numbers import Number from homeassistant.const import ( @@ -10,7 +12,12 @@ from homeassistant.const import ( VOLUME_MILLILITERS, ) -VALID_UNITS = [VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE] +VALID_UNITS: tuple[str, ...] = ( + VOLUME_LITERS, + VOLUME_MILLILITERS, + VOLUME_GALLONS, + VOLUME_FLUID_OUNCE, +) def __liter_to_gallon(liter: float) -> float: diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 new file mode 100644 index 00000000000..be07d6c8aba --- /dev/null +++ b/machine/khadas-vim3 @@ -0,0 +1,5 @@ +ARG BUILD_VERSION +FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + usbutils diff --git a/mypy.ini b/mypy.ini index 4472311279f..e38897bf303 100644 --- a/mypy.ini +++ b/mypy.ini @@ -110,6 +110,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airvisual.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.aladdin_connect.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -154,6 +165,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ambient_station.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.ampio.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -209,6 +231,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.braviatv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.brother.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -286,6 +319,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.devolo_home_control.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dnsip.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -330,6 +374,39 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.esphome.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.energy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.fastdotcom.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fitbit.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -341,6 +418,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.flunearyou.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.forecast_solar.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -374,6 +462,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.fritz.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.geo_location.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -407,6 +506,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.guardian.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.history.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -506,6 +616,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.lcn.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.light.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -583,6 +704,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.nest.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.netatmo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.network.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -616,6 +759,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.notion.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.number.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -638,6 +792,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.openuv.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.persistent_notification.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -671,6 +836,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rainmachine.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.recollect_waste.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.recorder.purge] check_untyped_defs = true disallow_incomplete_defs = true @@ -715,6 +902,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.renault.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.rituals_perfume_genie.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.scene.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -748,6 +957,28 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.shelly.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-homeassistant.components.simplisafe.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.slack.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -814,6 +1045,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.switcher_kis.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.synology_dsm.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -836,6 +1078,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tag.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tcp.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -847,6 +1100,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tile.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tts.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -985,9 +1249,6 @@ ignore_errors = true [mypy-homeassistant.components.aemet.*] ignore_errors = true -[mypy-homeassistant.components.alarmdecoder.*] -ignore_errors = true - [mypy-homeassistant.components.alexa.*] ignore_errors = true @@ -1006,15 +1267,9 @@ ignore_errors = true [mypy-homeassistant.components.atag.*] ignore_errors = true -[mypy-homeassistant.components.aurora.*] -ignore_errors = true - [mypy-homeassistant.components.awair.*] ignore_errors = true -[mypy-homeassistant.components.azure_devops.*] -ignore_errors = true - [mypy-homeassistant.components.azure_event_hub.*] ignore_errors = true @@ -1024,21 +1279,12 @@ ignore_errors = true [mypy-homeassistant.components.bmw_connected_drive.*] ignore_errors = true -[mypy-homeassistant.components.bsblan.*] -ignore_errors = true - -[mypy-homeassistant.components.cast.*] -ignore_errors = true - [mypy-homeassistant.components.cert_expiry.*] ignore_errors = true [mypy-homeassistant.components.climacell.*] ignore_errors = true -[mypy-homeassistant.components.climate.*] -ignore_errors = true - [mypy-homeassistant.components.cloud.*] ignore_errors = true @@ -1048,9 +1294,6 @@ ignore_errors = true [mypy-homeassistant.components.config.*] ignore_errors = true -[mypy-homeassistant.components.control4.*] -ignore_errors = true - [mypy-homeassistant.components.conversation.*] ignore_errors = true @@ -1063,9 +1306,6 @@ ignore_errors = true [mypy-homeassistant.components.denonavr.*] ignore_errors = true -[mypy-homeassistant.components.devolo_home_control.*] -ignore_errors = true - [mypy-homeassistant.components.dhcp.*] ignore_errors = true @@ -1075,15 +1315,6 @@ ignore_errors = true [mypy-homeassistant.components.doorbird.*] ignore_errors = true -[mypy-homeassistant.components.dynalite.*] -ignore_errors = true - -[mypy-homeassistant.components.eafm.*] -ignore_errors = true - -[mypy-homeassistant.components.edl21.*] -ignore_errors = true - [mypy-homeassistant.components.elkm1.*] ignore_errors = true @@ -1096,15 +1327,9 @@ ignore_errors = true [mypy-homeassistant.components.entur_public_transport.*] ignore_errors = true -[mypy-homeassistant.components.esphome.*] -ignore_errors = true - [mypy-homeassistant.components.evohome.*] ignore_errors = true -[mypy-homeassistant.components.fan.*] -ignore_errors = true - [mypy-homeassistant.components.filter.*] ignore_errors = true @@ -1129,18 +1354,12 @@ ignore_errors = true [mypy-homeassistant.components.freebox.*] ignore_errors = true -[mypy-homeassistant.components.garmin_connect.*] -ignore_errors = true - [mypy-homeassistant.components.geniushub.*] ignore_errors = true [mypy-homeassistant.components.glances.*] ignore_errors = true -[mypy-homeassistant.components.gogogate2.*] -ignore_errors = true - [mypy-homeassistant.components.google_assistant.*] ignore_errors = true @@ -1162,9 +1381,6 @@ ignore_errors = true [mypy-homeassistant.components.gtfs.*] ignore_errors = true -[mypy-homeassistant.components.guardian.*] -ignore_errors = true - [mypy-homeassistant.components.habitica.*] ignore_errors = true @@ -1189,24 +1405,6 @@ ignore_errors = true [mypy-homeassistant.components.home_plus_control.*] ignore_errors = true -[mypy-homeassistant.components.homeassistant.triggers.homeassistant] -ignore_errors = true - -[mypy-homeassistant.components.homeassistant.triggers.numeric_state] -ignore_errors = true - -[mypy-homeassistant.components.homeassistant.triggers.time_pattern] -ignore_errors = true - -[mypy-homeassistant.components.homeassistant.triggers.time] -ignore_errors = true - -[mypy-homeassistant.components.homeassistant.triggers.state] -ignore_errors = true - -[mypy-homeassistant.components.homeassistant.scene] -ignore_errors = true - [mypy-homeassistant.components.homekit.*] ignore_errors = true @@ -1219,9 +1417,6 @@ ignore_errors = true [mypy-homeassistant.components.honeywell.*] ignore_errors = true -[mypy-homeassistant.components.huisbaasje.*] -ignore_errors = true - [mypy-homeassistant.components.humidifier.*] ignore_errors = true @@ -1246,9 +1441,6 @@ ignore_errors = true [mypy-homeassistant.components.input_number.*] ignore_errors = true -[mypy-homeassistant.components.insteon.*] -ignore_errors = true - [mypy-homeassistant.components.ipp.*] ignore_errors = true @@ -1321,9 +1513,6 @@ ignore_errors = true [mypy-homeassistant.components.motion_blinds.*] ignore_errors = true -[mypy-homeassistant.components.mqtt.*] -ignore_errors = true - [mypy-homeassistant.components.mullvad.*] ignore_errors = true @@ -1333,10 +1522,7 @@ ignore_errors = true [mypy-homeassistant.components.ness_alarm.*] ignore_errors = true -[mypy-homeassistant.components.nest.*] -ignore_errors = true - -[mypy-homeassistant.components.netatmo.*] +[mypy-homeassistant.components.nest.legacy.*] ignore_errors = true [mypy-homeassistant.components.netio.*] @@ -1354,9 +1540,6 @@ ignore_errors = true [mypy-homeassistant.components.norway_air.*] ignore_errors = true -[mypy-homeassistant.components.notion.*] -ignore_errors = true - [mypy-homeassistant.components.nsw_fuel_station.*] ignore_errors = true @@ -1426,24 +1609,12 @@ ignore_errors = true [mypy-homeassistant.components.rachio.*] ignore_errors = true -[mypy-homeassistant.components.rainmachine.*] -ignore_errors = true - -[mypy-homeassistant.components.recollect_waste.*] -ignore_errors = true - -[mypy-homeassistant.components.recorder.*] -ignore_errors = true - [mypy-homeassistant.components.reddit.*] ignore_errors = true [mypy-homeassistant.components.ring.*] ignore_errors = true -[mypy-homeassistant.components.roku.*] -ignore_errors = true - [mypy-homeassistant.components.rpi_power.*] ignore_errors = true @@ -1456,9 +1627,6 @@ ignore_errors = true [mypy-homeassistant.components.screenlogic.*] ignore_errors = true -[mypy-homeassistant.components.script.*] -ignore_errors = true - [mypy-homeassistant.components.search.*] ignore_errors = true @@ -1519,9 +1687,6 @@ ignore_errors = true [mypy-homeassistant.components.switchbot.*] ignore_errors = true -[mypy-homeassistant.components.switcher_kis.*] -ignore_errors = true - [mypy-homeassistant.components.synology_srm.*] ignore_errors = true @@ -1534,9 +1699,6 @@ ignore_errors = true [mypy-homeassistant.components.tado.*] ignore_errors = true -[mypy-homeassistant.components.tasmota.*] -ignore_errors = true - [mypy-homeassistant.components.telegram_bot.*] ignore_errors = true @@ -1558,9 +1720,6 @@ ignore_errors = true [mypy-homeassistant.components.tplink.*] ignore_errors = true -[mypy-homeassistant.components.trace.*] -ignore_errors = true - [mypy-homeassistant.components.tradfri.*] ignore_errors = true @@ -1603,9 +1762,6 @@ ignore_errors = true [mypy-homeassistant.components.withings.*] ignore_errors = true -[mypy-homeassistant.components.wunderground.*] -ignore_errors = true - [mypy-homeassistant.components.xbox.*] ignore_errors = true diff --git a/pyproject.toml b/pyproject.toml index f8d47624c8f..8ca6a06868f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ ignore = [ jobs = 2 init-hook='from pylint.config.find_default_config_files import find_default_config_files; from pathlib import Path; import sys; sys.path.append(str(Path(Path(list(find_default_config_files())[0]).parent, "pylint/plugins")))' load-plugins = [ + "pylint.extensions.code_style", "pylint.extensions.typing", "pylint_strict_informational", "hass_constructor", @@ -32,7 +33,9 @@ load-plugins = [ "hass_logger", ] persistent = false -extension-pkg-whitelist = [ +extension-pkg-allow-list = [ + "av.audio.stream", + "av.stream", "ciso8601", "cv2", ] @@ -66,6 +69,9 @@ good-names = [ # inconsistent-return-statements - doesn't handle raise # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this +# --- +# Enable once current issues are fixed: +# consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) disable = [ "format", "abstract-class-little-used", @@ -88,6 +94,7 @@ disable = [ "too-many-boolean-expressions", "unused-argument", "wrong-import-order", + "consider-using-namedtuple-or-dataclass", ] enable = [ #"useless-suppression", # temporarily every now and then to clean them up diff --git a/requirements.txt b/requirements.txt index ad9c2717e94..dd445b8a7e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ backports.zoneinfo;python_version<"3.9" bcrypt==3.1.7 certifi>=2020.12.5 ciso8601==2.1.3 -httpx==0.18.0 +httpx==0.18.2 jinja2==3.0.1 PyJWT==1.7.1 cryptography==3.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 7081245a660..f15f5cf66ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.5.1 +HAP-python==3.6.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 @@ -39,7 +39,7 @@ PyMata==2.20 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.3.0 +PyNaCl==1.4.0 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit @@ -61,7 +61,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.5.0 # homeassistant.components.vicare -PyViCare==0.2.5 +PyViCare==1.0.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.13.4 @@ -69,6 +69,7 @@ PyXiaomiGateway==0.13.4 # homeassistant.components.bmp280 # homeassistant.components.mcp23017 # homeassistant.components.rpi_gpio +# homeassistant.components.rpi_rf # RPi.GPIO==0.7.1a4 # homeassistant.components.remember_the_milk @@ -104,6 +105,9 @@ adafruit-circuitpython-dht==3.6.0 # homeassistant.components.mcp23017 adafruit-circuitpython-mcp230xx==2.2.2 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 @@ -114,7 +118,7 @@ adext==0.4.2 adguardhome==0.5.0 # homeassistant.components.advantage_air -advantage_air==0.2.1 +advantage_air==0.2.5 # homeassistant.components.frontier_silicon afsapi==0.0.4 @@ -135,7 +139,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.4 +aioambient==1.2.5 # homeassistant.components.asuswrt aioasuswrt==1.3.4 @@ -160,7 +164,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.0.1 +aioesphomeapi==6.0.1 # homeassistant.components.flo aioflo==0.4.1 @@ -169,20 +173,20 @@ aioflo==0.4.1 aioftp==0.12.0 # homeassistant.components.guardian -aioguardian==1.0.4 +aioguardian==1.0.8 # homeassistant.components.harmony aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.5.1 +aiohomekit==0.6.0 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.5.1 +aiohue==2.6.1 # homeassistant.components.imap aioimaplib==0.9.0 @@ -200,7 +204,7 @@ aiolifx==0.6.9 aiolifx_effects==0.2.2 # homeassistant.components.lutron_caseta -aiolip==1.1.4 +aiolip==1.1.6 # homeassistant.components.lyric aiolyric==1.0.7 @@ -209,13 +213,13 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.0 +aiomusiccast==0.8.2 # homeassistant.components.keyboard_remote aionotify==0.2.0 # homeassistant.components.notion -aionotion==1.1.0 +aionotion==3.0.2 # homeassistant.components.acmeda aiopulse==0.4.2 @@ -230,13 +234,13 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.5 +aiorecollect==1.0.7 # homeassistant.components.shelly aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==1.2.3 +aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -358,10 +362,10 @@ beautifulsoup4==4.9.3 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.25.0 +bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.15 +bimmer_connected==0.7.16 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -373,7 +377,7 @@ blebox_uniapi==1.3.3 blinkpy==0.17.0 # homeassistant.components.blinksticklight -blinkstick==1.1.8 +blinkstick==1.2.0 # homeassistant.components.blinkt # blinkt==0.1.0 @@ -385,6 +389,9 @@ blockchain==1.4.4 # homeassistant.components.miflora # bluepy==1.3.0 +# homeassistant.components.bme280 +# bme280spi==0.2.0 + # homeassistant.components.bme680 # bme680==1.0.5 @@ -476,7 +483,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.3.0 +debugpy==1.4.0 # homeassistant.components.decora # decora==0.6 @@ -497,7 +504,7 @@ deluge-client==1.7.1 denonavr==0.10.8 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.17.3 +devolo-home-control-api==0.17.4 # homeassistant.components.directv directv==0.4.0 @@ -614,6 +621,9 @@ fitbit==0.3.1 # homeassistant.components.fixer fixerio==1.0.0a0 +# homeassistant.components.flipr +flipr-api==1.4.1 + # homeassistant.components.flux_led flux_led==0.22 @@ -624,7 +634,7 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==1.3.1 +forecast_solar==2.0.0 # homeassistant.components.fortios fortiosapi==1.0.5 @@ -637,11 +647,7 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -# homeassistant.components.fritzbox_netmonitor -fritzconnection==1.4.2 - -# homeassistant.components.fritz -fritzprofiles==0.6.1 +fritzconnection==1.6.0 # homeassistant.components.google_translate gTTS==2.2.3 @@ -649,9 +655,6 @@ gTTS==2.2.3 # homeassistant.components.garages_amsterdam garages-amsterdam==2.1.1 -# homeassistant.components.garmin_connect -garminconnect_ha==0.1.6 - # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -671,14 +674,14 @@ georss_ign_sismologia_client==0.3 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.5 -# homeassistant.components.huawei_lte # homeassistant.components.kef # homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker +# homeassistant.components.samsungtv getmac==0.8.2 # homeassistant.components.gios -gios==1.0.2 +gios==2.0.0 # homeassistant.components.gitter gitterpy==0.1.7 @@ -702,7 +705,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.2.12 +google-nest-sdm==0.3.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 @@ -717,7 +720,7 @@ gpiozero==1.5.1 gps3==0.33.3 # homeassistant.components.gree -greeclimate==0.11.7 +greeclimate==0.11.8 # homeassistant.components.greeneye_monitor greeneye_monitor==2.1 @@ -753,7 +756,7 @@ hass-nabucasa==0.44.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.2.19 +hatasmota==0.2.20 # homeassistant.components.jewish_calendar hdate==0.10.2 @@ -777,10 +780,10 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.1 +holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210707.0 +home-assistant-frontend==20210803.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -789,7 +792,7 @@ homeassistant-pyozw==0.1.10 homeconnect==0.6.3 # homeassistant.components.homematicip_cloud -homematicip==0.13.1 +homematicip==1.0.1 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -939,7 +942,7 @@ lyft_rides==0.2 magicseaweed==1.0.3 # homeassistant.components.matrix -matrix-client==0.3.2 +matrix-client==0.4.0 # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -981,7 +984,7 @@ mitemp_bt==0.0.3 motionblinds==0.4.10 # homeassistant.components.motioneye -motioneye-client==0.3.6 +motioneye-client==0.3.11 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -1020,7 +1023,7 @@ nettigo-air-monitor==1.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.10 +nexia==0.9.11 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 @@ -1034,11 +1037,14 @@ niluclient==0.1.2 # homeassistant.components.noaa_tides noaa-coops==0.1.8 +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 + # homeassistant.components.notify_events notify-events==1.0.4 # homeassistant.components.nederlandse_spoorwegen -nsapi==3.0.4 +nsapi==3.0.5 # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.1.0 @@ -1054,7 +1060,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.20.3 +numpy==1.21.1 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1081,10 +1087,10 @@ onkyo-eiscp==1.2.7 onvif-zeep-async==1.0.0 # homeassistant.components.opengarage -open-garage==0.1.4 +open-garage==0.1.5 # homeassistant.components.opencv -# opencv-python-headless==4.4.0.42 +# opencv-python-headless==4.5.2.54 # homeassistant.components.openerz openerz-api==0.1.0 @@ -1172,7 +1178,7 @@ pillow==8.2.0 pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.6.1 +plexapi==4.7.0 # homeassistant.components.plex plexauth==0.0.6 @@ -1247,6 +1253,9 @@ py-nightscout==1.2.2 # homeassistant.components.schluter py-schluter==0.1.7 +# homeassistant.components.synology_dsm +py-synologydsm-api==1.0.3 + # homeassistant.components.zabbix py-zabbix==1.1.7 @@ -1276,7 +1285,7 @@ pyRFXtrx==0.27.0 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.18.0 +pyTibber==0.19.0 # homeassistant.components.dlink pyW215==0.7.0 @@ -1300,7 +1309,7 @@ pyaftership==0.1.2 pyairnow==1.1.0 # homeassistant.components.airvisual -pyairvisual==5.0.8 +pyairvisual==5.0.9 # homeassistant.components.almond pyalmond==0.0.2 @@ -1312,13 +1321,13 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.0 +pyatmo==5.2.3 # homeassistant.components.atome pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.8.1 +pyatv==0.8.2 # homeassistant.components.bbox pybbox==0.0.5-alpha @@ -1435,7 +1444,7 @@ pyflic==2.0.3 pyflume==0.5.5 # homeassistant.components.flunearyou -pyflunearyou==1.0.7 +pyflunearyou==2.0.2 # homeassistant.components.futurenow pyfnip==0.2 @@ -1447,10 +1456,10 @@ pyforked-daapd==0.1.11 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.4.2 +pyfritzhome==0.6.2 # homeassistant.components.fronius -pyfronius==0.5.2 +pyfronius==0.5.3 # homeassistant.components.ifttt pyfttt==0.3 @@ -1553,13 +1562,13 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.3.1 +pylitterbot==2021.7.2 # homeassistant.components.loopenergy pyloopenergy==0.2.1 # homeassistant.components.lutron_caseta -pylutron-caseta==0.10.0 +pylutron-caseta==0.11.0 # homeassistant.components.lutron pylutron==0.2.8 @@ -1683,6 +1692,9 @@ pypoint==2.1.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 +# homeassistant.components.prosegur +pyprosegur==0.0.5 + # homeassistant.components.ps4 pyps4-2ndscreen==1.2.0 @@ -1708,7 +1720,7 @@ pyrepetier==3.0.5 pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie -pyrituals==0.0.4 +pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.12 @@ -1749,7 +1761,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.4 +pysma==0.6.5 # homeassistant.components.smappee pysmappee==0.2.25 @@ -1772,9 +1784,6 @@ pysnmp==4.4.12 # homeassistant.components.soma pysoma==0.0.10 -# homeassistant.components.sonos -pysonos==0.0.53 - # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1920,7 +1929,7 @@ python_opendata_transport==0.2.1 pythonegardia==1.0.40 # homeassistant.components.tile -pytile==5.2.2 +pytile==5.2.3 # homeassistant.components.touchline pytouchline==0.7 @@ -1966,7 +1975,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.5 +pywemo==0.6.6 # homeassistant.components.wilight pywilight==0.0.70 @@ -2002,7 +2011,10 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==3.0.0 +regenmaschine==3.1.5 + +# homeassistant.components.renault +renault-api==0.1.4 # homeassistant.components.python_script restrictedpython==5.1 @@ -2032,7 +2044,7 @@ rokuecp==0.8.1 roombapy==1.6.3 # homeassistant.components.roon -roonapi==0.0.37 +roonapi==0.0.38 # homeassistant.components.rova rova==0.2.1 @@ -2084,7 +2096,7 @@ sense-hat==2.2.0 sense_energy==0.9.0 # homeassistant.components.sentry -sentry-sdk==1.1.0 +sentry-sdk==1.3.0 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -2102,7 +2114,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.2 +simplisafe-python==11.0.3 # homeassistant.components.sisyphus sisyphus-control==3.0 @@ -2139,6 +2151,9 @@ smhi-pkg==1.0.15 # homeassistant.components.snapcast snapcast==2.1.3 +# homeassistant.components.sonos +soco==0.23.2 + # homeassistant.components.solaredge_local solaredge-local==0.2.0 @@ -2218,9 +2233,6 @@ swisshydrodata==0.1.0 # homeassistant.components.synology_srm synology-srm==0.2.0 -# homeassistant.components.synology_dsm -synologydsm-api==1.0.2 - # homeassistant.components.system_bridge systembridge==1.1.5 @@ -2368,13 +2380,13 @@ webexteamssdk==1.1.1 wiffi==1.0.1 # homeassistant.components.wirelesstag -wirelesstagpy==0.4.1 +wirelesstagpy==0.5.0 # homeassistant.components.withings withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.1 +wled==0.8.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -2389,7 +2401,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.18.8 +xknx==0.18.9 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2403,10 +2415,10 @@ xmltodict==0.12.0 xs1-api-client==3.0.0 # homeassistant.components.yale_smart_alarm -yalesmartalarmclient==0.3.3 +yalesmartalarmclient==0.3.4 # homeassistant.components.august -yalexs==1.1.11 +yalexs==1.1.13 # homeassistant.components.yeelight yeelight==0.6.3 @@ -2414,6 +2426,9 @@ yeelight==0.6.3 # homeassistant.components.yeelightsunflower yeelightsunflower==0.0.10 +# homeassistant.components.youless +youless-api==0.10 + # homeassistant.components.media_extractor youtube_dl==2021.04.26 @@ -2424,7 +2439,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.32.1 +zeroconf==0.33.2 # homeassistant.components.zha zha-quirks==0.0.59 @@ -2448,13 +2463,13 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.1 +zigpy-znp==0.5.2 # homeassistant.components.zha -zigpy==0.35.2 +zigpy==0.36.1 # homeassistant.components.zoneminder zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.1 +zwave-js-server-python==0.28.0 diff --git a/requirements_test.txt b/requirements_test.txt index 660ea2a11fd..aceec3229a9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ jsonpickle==1.4.1 mock-open==1.4.0 mypy==0.902 pre-commit==2.13.0 -pylint==2.8.3 +pylint==2.9.5 pipdeptree==1.0.0 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41b4b8fa0b1..29a8352f433 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,14 +7,14 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==3.5.1 +HAP-python==3.6.0 # homeassistant.components.flick_electric PyFlick==0.0.2 # homeassistant.components.mobile_app # homeassistant.components.owntracks -PyNaCl==1.3.0 +PyNaCl==1.4.0 # homeassistant.auth.mfa_modules.totp # homeassistant.components.homekit @@ -47,6 +47,9 @@ abodepy==1.2.0 # homeassistant.components.accuweather accuweather==0.2.0 +# homeassistant.components.adax +adax==0.0.1 + # homeassistant.components.androidtv adb-shell[async]==0.3.4 @@ -57,7 +60,7 @@ adext==0.4.2 adguardhome==0.5.0 # homeassistant.components.advantage_air -advantage_air==0.2.1 +advantage_air==0.2.5 # homeassistant.components.agent_dvr agent-py==0.0.23 @@ -75,7 +78,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.5 # homeassistant.components.ambient_station -aioambient==1.2.4 +aioambient==1.2.5 # homeassistant.components.asuswrt aioasuswrt==1.3.4 @@ -100,32 +103,32 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.0.1 +aioesphomeapi==6.0.1 # homeassistant.components.flo aioflo==0.4.1 # homeassistant.components.guardian -aioguardian==1.0.4 +aioguardian==1.0.8 # homeassistant.components.harmony aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.5.1 +aiohomekit==0.6.0 # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==2.5.1 +aiohue==2.6.1 # homeassistant.components.apache_kafka aiokafka==0.6.0 # homeassistant.components.lutron_caseta -aiolip==1.1.4 +aiolip==1.1.6 # homeassistant.components.lyric aiolyric==1.0.7 @@ -134,10 +137,10 @@ aiolyric==1.0.7 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.8.0 +aiomusiccast==0.8.2 # homeassistant.components.notion -aionotion==1.1.0 +aionotion==3.0.2 # homeassistant.components.acmeda aiopulse==0.4.2 @@ -152,13 +155,13 @@ aiopvpc==2.2.0 aiopylgtv==0.4.0 # homeassistant.components.recollect_waste -aiorecollect==1.0.5 +aiorecollect==1.0.7 # homeassistant.components.shelly aioshelly==0.6.4 # homeassistant.components.switcher_kis -aioswitcher==1.2.3 +aioswitcher==2.0.4 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -214,10 +217,10 @@ azure-eventhub==5.5.0 base36==0.1.1 # homeassistant.components.zha -bellows==0.25.0 +bellows==0.26.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.15 +bimmer_connected==0.7.16 # homeassistant.components.blebox blebox_uniapi==1.3.3 @@ -249,6 +252,9 @@ buienradar==1.0.4 # homeassistant.components.caldav caldav==0.7.1 +# homeassistant.components.co2signal +co2signal==0.4.2 + # homeassistant.components.coinbase coinbase==2.1.0 @@ -273,7 +279,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.debugpy -debugpy==1.3.0 +debugpy==1.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns @@ -285,7 +291,7 @@ defusedxml==0.7.1 denonavr==0.10.8 # homeassistant.components.devolo_home_control -devolo-home-control-api==0.17.3 +devolo-home-control-api==0.17.4 # homeassistant.components.directv directv==0.4.0 @@ -332,6 +338,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.flipr +flipr-api==1.4.1 + # homeassistant.components.homekit fnvhash==0.1.0 @@ -339,18 +348,14 @@ fnvhash==0.1.0 foobot_async==1.0.0 # homeassistant.components.forecast_solar -forecast_solar==1.3.1 +forecast_solar==2.0.0 # homeassistant.components.freebox freebox-api==0.0.10 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -# homeassistant.components.fritzbox_netmonitor -fritzconnection==1.4.2 - -# homeassistant.components.fritz -fritzprofiles==0.6.1 +fritzconnection==1.6.0 # homeassistant.components.google_translate gTTS==2.2.3 @@ -358,9 +363,6 @@ gTTS==2.2.3 # homeassistant.components.garages_amsterdam garages-amsterdam==2.1.1 -# homeassistant.components.garmin_connect -garminconnect_ha==0.1.6 - # homeassistant.components.geo_json_events # homeassistant.components.usgs_earthquakes_feed geojson_client==0.6 @@ -377,14 +379,14 @@ georss_ign_sismologia_client==0.3 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.5 -# homeassistant.components.huawei_lte # homeassistant.components.kef # homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker +# homeassistant.components.samsungtv getmac==0.8.2 # homeassistant.components.gios -gios==1.0.2 +gios==2.0.0 # homeassistant.components.glances glances_api==0.2.0 @@ -399,13 +401,13 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.2.12 +google-nest-sdm==0.3.5 # homeassistant.components.google_travel_time googlemaps==2.5.1 # homeassistant.components.gree -greeclimate==0.11.7 +greeclimate==0.11.8 # homeassistant.components.growatt_server growattServer==1.0.1 @@ -429,7 +431,7 @@ hangups==0.4.14 hass-nabucasa==0.44.0 # homeassistant.components.tasmota -hatasmota==0.2.19 +hatasmota==0.2.20 # homeassistant.components.jewish_calendar hdate==0.10.2 @@ -444,10 +446,10 @@ hlk-sw16==0.0.9 hole==0.5.1 # homeassistant.components.workday -holidays==0.11.1 +holidays==0.11.2 # homeassistant.components.frontend -home-assistant-frontend==20210707.0 +home-assistant-frontend==20210803.2 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -456,7 +458,7 @@ homeassistant-pyozw==0.1.10 homeconnect==0.6.3 # homeassistant.components.homematicip_cloud -homematicip==0.13.1 +homematicip==1.0.1 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 @@ -550,7 +552,7 @@ minio==4.0.9 motionblinds==0.4.10 # homeassistant.components.motioneye -motioneye-client==0.3.6 +motioneye-client==0.3.11 # homeassistant.components.mullvad mullvad-api==1.0.0 @@ -574,7 +576,10 @@ netdisco==2.9.0 nettigo-air-monitor==1.0.0 # homeassistant.components.nexia -nexia==0.9.10 +nexia==0.9.11 + +# homeassistant.components.nfandroidtv +notifications-android-tv==0.1.2 # homeassistant.components.notify_events notify-events==1.0.4 @@ -593,7 +598,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.20.3 +numpy==1.21.1 # homeassistant.components.google oauth2client==4.0.0 @@ -648,7 +653,7 @@ pilight==0.1.1 pillow==8.2.0 # homeassistant.components.plex -plexapi==4.6.1 +plexapi==4.7.0 # homeassistant.components.plex plexauth==0.0.6 @@ -696,6 +701,9 @@ py-melissa-climate==2.1.4 # homeassistant.components.nightscout py-nightscout==1.2.2 +# homeassistant.components.synology_dsm +py-synologydsm-api==1.0.3 + # homeassistant.components.seventeentrack py17track==3.2.1 @@ -716,7 +724,7 @@ pyMetno==0.8.3 pyRFXtrx==0.27.0 # homeassistant.components.tibber -pyTibber==0.18.0 +pyTibber==0.19.0 # homeassistant.components.nextbus py_nextbusnext==0.1.4 @@ -728,7 +736,7 @@ pyaehw4a1==0.3.9 pyairnow==1.1.0 # homeassistant.components.airvisual -pyairvisual==5.0.8 +pyairvisual==5.0.9 # homeassistant.components.almond pyalmond==0.0.2 @@ -740,10 +748,10 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.2.0 +pyatmo==5.2.3 # homeassistant.components.apple_tv -pyatv==0.8.1 +pyatv==0.8.2 # homeassistant.components.blackbird pyblackbird==0.5 @@ -797,7 +805,7 @@ pyfireservicerota==0.0.43 pyflume==0.5.5 # homeassistant.components.flunearyou -pyflunearyou==1.0.7 +pyflunearyou==2.0.2 # homeassistant.components.forked_daapd pyforked-daapd==0.1.11 @@ -806,7 +814,7 @@ pyforked-daapd==0.1.11 pyfreedompro==1.1.0 # homeassistant.components.fritzbox -pyfritzhome==0.4.2 +pyfritzhome==0.6.2 # homeassistant.components.ifttt pyfttt==0.3 @@ -876,10 +884,10 @@ pylibrespot-java==0.1.0 pylitejet==0.3.0 # homeassistant.components.litterrobot -pylitterbot==2021.3.1 +pylitterbot==2021.7.2 # homeassistant.components.lutron_caseta -pylutron-caseta==0.10.0 +pylutron-caseta==0.11.0 # homeassistant.components.mailgun pymailgunner==1.4 @@ -961,6 +969,9 @@ pypoint==2.1.0 # homeassistant.components.profiler pyprof2calltree==1.4.5 +# homeassistant.components.prosegur +pyprosegur==0.0.5 + # homeassistant.components.ps4 pyps4-2ndscreen==1.2.0 @@ -971,7 +982,7 @@ pyqwikswitch==0.93 pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie -pyrituals==0.0.4 +pyrituals==0.0.6 # homeassistant.components.ruckus_unleashed pyruckus==0.12 @@ -991,7 +1002,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.4 +pysma==0.6.5 # homeassistant.components.smappee pysmappee==0.2.25 @@ -1005,9 +1016,6 @@ pysmartthings==0.7.6 # homeassistant.components.soma pysoma==0.0.10 -# homeassistant.components.sonos -pysonos==0.0.53 - # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1060,7 +1068,7 @@ python-velbus==2.1.2 python_awair==0.2.1 # homeassistant.components.tile -pytile==5.2.2 +pytile==5.2.3 # homeassistant.components.traccar pytraccar==0.9.0 @@ -1084,7 +1092,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.5 +pywemo==0.6.6 # homeassistant.components.wilight pywilight==0.0.70 @@ -1096,7 +1104,10 @@ pyzerproc==0.4.8 rachiopy==1.0.3 # homeassistant.components.rainmachine -regenmaschine==3.0.0 +regenmaschine==3.1.5 + +# homeassistant.components.renault +renault-api==0.1.4 # homeassistant.components.python_script restrictedpython==5.1 @@ -1114,7 +1125,7 @@ rokuecp==0.8.1 roombapy==1.6.3 # homeassistant.components.roon -roonapi==0.0.37 +roonapi==0.0.38 # homeassistant.components.rpi_power rpi-bad-power==0.1.0 @@ -1139,7 +1150,7 @@ screenlogicpy==0.4.1 sense_energy==0.9.0 # homeassistant.components.sentry -sentry-sdk==1.1.0 +sentry-sdk==1.3.0 # homeassistant.components.sharkiq sharkiqpy==0.1.8 @@ -1148,7 +1159,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.2 +simplisafe-python==11.0.3 # homeassistant.components.slack slackclient==2.5.0 @@ -1165,6 +1176,9 @@ smarthab==0.21 # homeassistant.components.smhi smhi-pkg==1.0.15 +# homeassistant.components.sonos +soco==0.23.2 + # homeassistant.components.solaredge solaredge==0.0.2 @@ -1217,9 +1231,6 @@ sunwatcher==0.2.1 # homeassistant.components.surepetcare surepy==0.7.0 -# homeassistant.components.synology_dsm -synologydsm-api==1.0.2 - # homeassistant.components.system_bridge systembridge==1.1.5 @@ -1295,7 +1306,7 @@ wiffi==1.0.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.1 +wled==0.8.0 # homeassistant.components.wolflink wolf_smartset==0.1.11 @@ -1304,7 +1315,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.18.8 +xknx==0.18.9 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1314,17 +1325,23 @@ xknx==0.18.8 # homeassistant.components.zestimate xmltodict==0.12.0 +# homeassistant.components.yale_smart_alarm +yalesmartalarmclient==0.3.4 + # homeassistant.components.august -yalexs==1.1.11 +yalexs==1.1.13 # homeassistant.components.yeelight yeelight==0.6.3 +# homeassistant.components.youless +youless-api==0.10 + # homeassistant.components.onvif zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.32.1 +zeroconf==0.33.2 # homeassistant.components.zha zha-quirks==0.0.59 @@ -1342,10 +1359,10 @@ zigpy-xbee==0.13.0 zigpy-zigate==0.7.3 # homeassistant.components.zha -zigpy-znp==0.5.1 +zigpy-znp==0.5.2 # homeassistant.components.zha -zigpy==0.35.2 +zigpy==0.36.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.1 +zwave-js-server-python==0.28.0 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 3f473ae1592..795a4c3bcd6 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,7 +1,7 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit bandit==1.7.0 -black==21.6b0 +black==21.7b0 codespell==2.0.0 flake8-comprehensions==3.5.0 flake8-docstrings==1.6.0 @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.7.0 pydocstyle==6.0.0 pyflakes==2.3.1 -pyupgrade==2.16.0 +pyupgrade==2.23.0 yamllint==1.26.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index dc0c5fa2c10..7dcc4f71fe8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -19,6 +19,7 @@ COMMENT_REQUIREMENTS = ( "beewi_smartclim", # depends on bluepy "blinkt", "bluepy", + "bme280spi", "bme680", "decora", "decora_wifi", @@ -83,10 +84,9 @@ enum34==1000000000.0.0 typing==1000000000.0.0 uuid==1000000000.0.0 -# httpcore 0.13.4 breaks several integrations -# https://github.com/home-assistant/core/issues/51778 -httpcore==0.13.3 - +# Temporary constraint on pandas, to unblock 2021.7 releases +# until we have fixed the wheels builds for newer versions. +pandas==1.3.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/script/hassfest/coverage.py b/script/hassfest/coverage.py index 1a4b1fbf8ba..1a8609bb4e8 100644 --- a/script/hassfest/coverage.py +++ b/script/hassfest/coverage.py @@ -28,11 +28,8 @@ ALLOWED_IGNORE_VIOLATIONS = { ("elkm1", "config_flow.py"), ("elkm1", "scene.py"), ("fibaro", "scene.py"), - ("flume", "config_flow.py"), ("hangouts", "config_flow.py"), ("harmony", "config_flow.py"), - ("hisense_aehw4a1", "config_flow.py"), - ("home_connect", "config_flow.py"), ("huawei_lte", "config_flow.py"), ("ifttt", "config_flow.py"), ("ios", "config_flow.py"), diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 00110e11fbc..797729542f4 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -93,6 +93,7 @@ NO_IOT_CLASS = [ "search", "select", "sensor", + "siren", "stt", "switch", "system_health", diff --git a/script/hassfest/model.py b/script/hassfest/model.py index 59d75be5c4a..b20df6ea42f 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -86,6 +86,16 @@ class Integration: """Return if integration is disabled.""" return self.manifest.get("disabled") + @property + def name(self) -> str: + """Return name of the integration.""" + return self.manifest["name"] + + @property + def quality_scale(self) -> str: + """Return quality scale of the integration.""" + return self.manifest.get("quality_scale") + @property def requirements(self) -> list[str]: """List of requirements.""" diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 601e6b55845..8ff72c332da 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -16,46 +16,33 @@ from .model import Config, Integration IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.adguard.*", "homeassistant.components.aemet.*", - "homeassistant.components.alarmdecoder.*", "homeassistant.components.alexa.*", "homeassistant.components.almond.*", "homeassistant.components.amcrest.*", "homeassistant.components.analytics.*", "homeassistant.components.asuswrt.*", "homeassistant.components.atag.*", - "homeassistant.components.aurora.*", "homeassistant.components.awair.*", - "homeassistant.components.azure_devops.*", "homeassistant.components.azure_event_hub.*", "homeassistant.components.blueprint.*", "homeassistant.components.bmw_connected_drive.*", - "homeassistant.components.bsblan.*", - "homeassistant.components.cast.*", "homeassistant.components.cert_expiry.*", "homeassistant.components.climacell.*", - "homeassistant.components.climate.*", "homeassistant.components.cloud.*", "homeassistant.components.cloudflare.*", "homeassistant.components.config.*", - "homeassistant.components.control4.*", "homeassistant.components.conversation.*", "homeassistant.components.deconz.*", "homeassistant.components.demo.*", "homeassistant.components.denonavr.*", - "homeassistant.components.devolo_home_control.*", "homeassistant.components.dhcp.*", "homeassistant.components.directv.*", "homeassistant.components.doorbird.*", - "homeassistant.components.dynalite.*", - "homeassistant.components.eafm.*", - "homeassistant.components.edl21.*", "homeassistant.components.elkm1.*", "homeassistant.components.emonitor.*", "homeassistant.components.enphase_envoy.*", "homeassistant.components.entur_public_transport.*", - "homeassistant.components.esphome.*", "homeassistant.components.evohome.*", - "homeassistant.components.fan.*", "homeassistant.components.filter.*", "homeassistant.components.fints.*", "homeassistant.components.fireservicerota.*", @@ -64,10 +51,8 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.fortios.*", "homeassistant.components.foscam.*", "homeassistant.components.freebox.*", - "homeassistant.components.garmin_connect.*", "homeassistant.components.geniushub.*", "homeassistant.components.glances.*", - "homeassistant.components.gogogate2.*", "homeassistant.components.google_assistant.*", "homeassistant.components.google_maps.*", "homeassistant.components.google_pubsub.*", @@ -75,7 +60,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.gree.*", "homeassistant.components.growatt_server.*", "homeassistant.components.gtfs.*", - "homeassistant.components.guardian.*", "homeassistant.components.habitica.*", "homeassistant.components.harmony.*", "homeassistant.components.hassio.*", @@ -84,17 +68,10 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.hisense_aehw4a1.*", "homeassistant.components.home_connect.*", "homeassistant.components.home_plus_control.*", - "homeassistant.components.homeassistant.triggers.homeassistant", - "homeassistant.components.homeassistant.triggers.numeric_state", - "homeassistant.components.homeassistant.triggers.time_pattern", - "homeassistant.components.homeassistant.triggers.time", - "homeassistant.components.homeassistant.triggers.state", - "homeassistant.components.homeassistant.scene", "homeassistant.components.homekit.*", "homeassistant.components.homekit_controller.*", "homeassistant.components.homematicip_cloud.*", "homeassistant.components.honeywell.*", - "homeassistant.components.huisbaasje.*", "homeassistant.components.humidifier.*", "homeassistant.components.iaqualink.*", "homeassistant.components.icloud.*", @@ -103,7 +80,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.influxdb.*", "homeassistant.components.input_datetime.*", "homeassistant.components.input_number.*", - "homeassistant.components.insteon.*", "homeassistant.components.ipp.*", "homeassistant.components.isy994.*", "homeassistant.components.izone.*", @@ -128,18 +104,15 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.minecraft_server.*", "homeassistant.components.mobile_app.*", "homeassistant.components.motion_blinds.*", - "homeassistant.components.mqtt.*", "homeassistant.components.mullvad.*", "homeassistant.components.neato.*", "homeassistant.components.ness_alarm.*", - "homeassistant.components.nest.*", - "homeassistant.components.netatmo.*", + "homeassistant.components.nest.legacy.*", "homeassistant.components.netio.*", "homeassistant.components.nightscout.*", "homeassistant.components.nilu.*", "homeassistant.components.nmap_tracker.*", "homeassistant.components.norway_air.*", - "homeassistant.components.notion.*", "homeassistant.components.nsw_fuel_station.*", "homeassistant.components.nuki.*", "homeassistant.components.nws.*", @@ -163,17 +136,12 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.profiler.*", "homeassistant.components.proxmoxve.*", "homeassistant.components.rachio.*", - "homeassistant.components.rainmachine.*", - "homeassistant.components.recollect_waste.*", - "homeassistant.components.recorder.*", "homeassistant.components.reddit.*", "homeassistant.components.ring.*", - "homeassistant.components.roku.*", "homeassistant.components.rpi_power.*", "homeassistant.components.ruckus_unleashed.*", "homeassistant.components.sabnzbd.*", "homeassistant.components.screenlogic.*", - "homeassistant.components.script.*", "homeassistant.components.search.*", "homeassistant.components.sense.*", "homeassistant.components.sesame.*", @@ -194,12 +162,10 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.stt.*", "homeassistant.components.surepetcare.*", "homeassistant.components.switchbot.*", - "homeassistant.components.switcher_kis.*", "homeassistant.components.synology_srm.*", "homeassistant.components.system_health.*", "homeassistant.components.system_log.*", "homeassistant.components.tado.*", - "homeassistant.components.tasmota.*", "homeassistant.components.telegram_bot.*", "homeassistant.components.template.*", "homeassistant.components.tesla.*", @@ -207,7 +173,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.todoist.*", "homeassistant.components.toon.*", "homeassistant.components.tplink.*", - "homeassistant.components.trace.*", "homeassistant.components.tradfri.*", "homeassistant.components.tuya.*", "homeassistant.components.unifi.*", @@ -222,7 +187,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.wemo.*", "homeassistant.components.wink.*", "homeassistant.components.withings.*", - "homeassistant.components.wunderground.*", "homeassistant.components.xbox.*", "homeassistant.components.xiaomi_aqara.*", "homeassistant.components.xiaomi_miio.*", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 4143d61ca5d..e24b37d71d9 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -21,6 +21,20 @@ REMOVED = 2 RE_REFERENCE = r"\[\%key:(.+)\%\]" +# Only allow translatino of integration names if they contain non-brand names +ALLOW_NAME_TRANSLATION = { + "cert_expiry", + "emulated_roku", + "garages_amsterdam", + "google_travel_time", + "homekit_controller", + "islamic_prayer_times", + "local_ip", + "nmap_tracker", + "rpi_power", + "waze_travel_time", +} + REMOVED_TITLE_MSG = ( "config.title key has been moved out of config and into the root of strings.json. " "Starting Home Assistant 0.109 you only need to define this key in the root " @@ -257,6 +271,20 @@ def validate_translation_file(config: Config, integration: Integration, all_stri if strings_file.name == "strings.json": find_references(strings, name, references) + if ( + integration.domain not in ALLOW_NAME_TRANSLATION + # Only enforce for core because custom integratinos can't be + # added to allow list. + and integration.core + and strings.get("title") == integration.name + and integration.quality_scale != "internal" + ): + integration.add_error( + "translations", + "Don't specify title in translation strings if it's a brand name " + "or add exception to ALLOW_NAME_TRANSLATION", + ) + platform_string_schema = gen_platform_strings_schema(config, integration) platform_strings = [integration.path.glob("strings.*.json")] diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 7ebc364d7ee..122d8570dc1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -113,7 +113,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow": info.update_manifest(config_flow=True) info.update_strings( - title=info.name, config={ "step": { "user": { @@ -138,7 +137,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow_discovery": info.update_manifest(config_flow=True) info.update_strings( - title=info.name, config={ "step": { "confirm": { @@ -155,7 +153,6 @@ def _custom_tasks(template, info: Info) -> None: elif template == "config_flow_oauth2": info.update_manifest(config_flow=True, dependencies=["http"]) info.update_strings( - title=info.name, config={ "step": { "pick_implementation": { diff --git a/script/scaffold/templates/config_flow/integration/const.py b/script/scaffold/templates/config_flow/integration/const.py new file mode 100644 index 00000000000..e8a1c494d49 --- /dev/null +++ b/script/scaffold/templates/config_flow/integration/const.py @@ -0,0 +1,3 @@ +"""Constants for the NEW_NAME integration.""" + +DOMAIN = "NEW_DOMAIN" diff --git a/script/scaffold/templates/config_flow_discovery/integration/const.py b/script/scaffold/templates/config_flow_discovery/integration/const.py new file mode 100644 index 00000000000..e8a1c494d49 --- /dev/null +++ b/script/scaffold/templates/config_flow_discovery/integration/const.py @@ -0,0 +1,3 @@ +"""Constants for the NEW_NAME integration.""" + +DOMAIN = "NEW_DOMAIN" diff --git a/setup.py b/setup.py index 758f4f3813d..db4e8a54d72 100755 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ REQUIRES = [ "bcrypt==3.1.7", "certifi>=2020.12.5", "ciso8601==2.1.3", - "httpx==0.18.0", + "httpx==0.18.2", "jinja2==3.0.1", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 0128c9794f3..4a763a6e995 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -335,7 +335,7 @@ async def test_saving_loading(hass, hass_storage): assert r_token.last_used_at is None assert r_token.last_used_ip is None else: - assert False, "Unknown client_id: %s" % r_token.client_id + assert False, f"Unknown client_id: {r_token.client_id}" async def test_cannot_retrieve_expired_access_token(hass): diff --git a/tests/common.py b/tests/common.py index 03b53294db0..5de58a08472 100644 --- a/tests/common.py +++ b/tests/common.py @@ -34,7 +34,7 @@ from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, _async_get_device_automations as async_get_device_automations, ) -from homeassistant.components.mqtt.models import Message +from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.config import async_process_component_config from homeassistant.const import ( DEVICE_DEFAULT_NAME, @@ -353,7 +353,7 @@ def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False): """Fire the MQTT message.""" if isinstance(payload, str): payload = payload.encode("utf-8") - msg = Message(topic, payload, qos, retain) + msg = ReceiveMessage(topic, payload, qos, retain) hass.data["mqtt"]._mqtt_handle_message(msg) diff --git a/tests/components/abode/test_config_flow.py b/tests/components/abode/test_config_flow.py index 806038194bb..b56762bff40 100644 --- a/tests/components/abode/test_config_flow.py +++ b/tests/components/abode/test_config_flow.py @@ -7,7 +7,7 @@ from abodepy.helpers.errors import MFA_CODE_REQUIRED from homeassistant import data_entry_flow from homeassistant.components.abode import config_flow from homeassistant.components.abode.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, @@ -46,17 +46,6 @@ async def test_one_config_allowed(hass): assert step_user_result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert step_user_result["reason"] == "single_instance_allowed" - conf = { - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_POLLING: False, - } - - import_config_result = await flow.async_step_import(conf) - - assert import_config_result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert import_config_result["reason"] == "single_instance_allowed" - async def test_invalid_credentials(hass): """Test that invalid credentials throws an error.""" @@ -90,29 +79,6 @@ async def test_connection_error(hass): assert result["errors"] == {"base": "cannot_connect"} -async def test_step_import(hass): - """Test that the import step works.""" - conf = { - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_POLLING: False, - } - - with patch("homeassistant.components.abode.config_flow.Abode"), patch( - "abodepy.UTILS" - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "user@email.com" - assert result["data"] == { - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_POLLING: False, - } - - async def test_step_user(hass): """Test that the user step works.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} diff --git a/tests/components/adax/__init__.py b/tests/components/adax/__init__.py new file mode 100644 index 00000000000..54a72856a85 --- /dev/null +++ b/tests/components/adax/__init__.py @@ -0,0 +1 @@ +"""Tests for the Adax integration.""" diff --git a/tests/components/adax/test_config_flow.py b/tests/components/adax/test_config_flow.py new file mode 100644 index 00000000000..f9638e52cbf --- /dev/null +++ b/tests/components/adax/test_config_flow.py @@ -0,0 +1,78 @@ +"""Test the Adax config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.adax.const import ACCOUNT_ID, DOMAIN +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TEST_DATA = { + ACCOUNT_ID: 12345, + CONF_PASSWORD: "pswd", +} + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch("adax.get_adax_token", return_value="test_token",), patch( + "homeassistant.components.adax.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_DATA["account_id"] + assert result2["data"] == { + "account_id": TEST_DATA["account_id"], + "password": TEST_DATA["password"], + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "adax.get_adax_token", + return_value=None, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_DATA, + ) + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_flow_entry_already_exists(hass: HomeAssistant) -> None: + """Test user input for config_entry that already exists.""" + + first_entry = MockConfigEntry( + domain="adax", + data=TEST_DATA, + unique_id=TEST_DATA[ACCOUNT_ID], + ) + first_entry.add_to_hass(hass) + + with patch("adax.get_adax_token", return_value="token"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=TEST_DATA + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/advantage_air/test_cover.py b/tests/components/advantage_air/test_cover.py index 29f0d288fdb..363db076ada 100644 --- a/tests/components/advantage_air/test_cover.py +++ b/tests/components/advantage_air/test_cover.py @@ -112,3 +112,35 @@ async def test_cover_async_setup_entry(hass, aioclient_mock): assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE assert aioclient_mock.mock_calls[-1][0] == "GET" assert aioclient_mock.mock_calls[-1][1].path == "/getSystemData" + + # Test controlling multiple Cover Zone Entity + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + { + ATTR_ENTITY_ID: [ + "cover.zone_open_without_sensor", + "cover.zone_closed_without_sensor", + ] + }, + blocking=True, + ) + assert len(aioclient_mock.mock_calls) == 11 + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + assert data["ac2"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_CLOSE + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + { + ATTR_ENTITY_ID: [ + "cover.zone_open_without_sensor", + "cover.zone_closed_without_sensor", + ] + }, + blocking=True, + ) + assert len(aioclient_mock.mock_calls) == 13 + data = loads(aioclient_mock.mock_calls[-2][1].query["json"]) + assert data["ac2"]["zones"]["z01"]["state"] == ADVANTAGE_AIR_STATE_OPEN + assert data["ac2"]["zones"]["z02"]["state"] == ADVANTAGE_AIR_STATE_OPEN diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 684b965d94f..997f11dea91 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -1,5 +1,6 @@ """Test the Advantage Air Sensor Platform.""" +from datetime import timedelta from json import loads from homeassistant.components.advantage_air.const import DOMAIN as ADVANTAGE_AIR_DOMAIN @@ -7,9 +8,12 @@ from homeassistant.components.advantage_air.sensor import ( ADVANTAGE_AIR_SERVICE_SET_TIME_TO, ADVANTAGE_AIR_SET_COUNTDOWN_VALUE, ) +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt +from tests.common import async_fire_time_changed from tests.components.advantage_air import ( TEST_SET_RESPONSE, TEST_SET_URL, @@ -125,3 +129,25 @@ async def test_sensor_platform(hass, aioclient_mock): entry = registry.async_get(entity_id) assert entry assert entry.unique_id == "uniqueid-ac1-z02-signal" + + # Test First Zone Temp Sensor (disabled by default) + entity_id = "sensor.zone_open_with_sensor_temperature" + + assert not hass.states.get(entity_id) + + registry.async_update_entity(entity_id=entity_id, disabled_by=None) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert int(state.state) == 25 + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "uniqueid-ac1-z01-temp" diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 1f66d901603..1fdb908d2e6 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -8,6 +8,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, @@ -50,6 +51,7 @@ def entity_reg(hass): (True, 0, const.SUPPORT_ALARM_ARM_AWAY, ["disarm", "arm_away"]), (True, 0, const.SUPPORT_ALARM_ARM_HOME, ["disarm", "arm_home"]), (True, 0, const.SUPPORT_ALARM_ARM_NIGHT, ["disarm", "arm_night"]), + (True, 0, const.SUPPORT_ALARM_ARM_VACATION, ["disarm", "arm_vacation"]), (True, 0, const.SUPPORT_ALARM_TRIGGER, ["disarm", "trigger"]), ], ) @@ -150,13 +152,14 @@ async def test_get_action_capabilities( "arm_away": {"extra_fields": []}, "arm_home": {"extra_fields": []}, "arm_night": {"extra_fields": []}, + "arm_vacation": {"extra_fields": []}, "disarm": { "extra_fields": [{"name": "code", "optional": True, "type": "string"}] }, "trigger": {"extra_fields": []}, } actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 5 + assert len(actions) == 6 for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -196,13 +199,16 @@ async def test_get_action_capabilities_arm_code( "arm_night": { "extra_fields": [{"name": "code", "optional": True, "type": "string"}] }, + "arm_vacation": { + "extra_fields": [{"name": "code", "optional": True, "type": "string"}] + }, "disarm": { "extra_fields": [{"name": "code", "optional": True, "type": "string"}] }, "trigger": {"extra_fields": []}, } actions = await async_get_device_automations(hass, "action", device_entry.id) - assert len(actions) == 5 + assert len(actions) == 6 for action in actions: capabilities = await async_get_device_automation_capabilities( hass, "action", action @@ -256,6 +262,18 @@ async def test_action(hass, enable_custom_integrations): "type": "arm_night", }, }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_arm_vacation", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "alarm_control_panel.alarm_no_arm_code", + "type": "arm_vacation", + }, + }, { "trigger": {"platform": "event", "event_type": "test_event_disarm"}, "action": { @@ -302,6 +320,13 @@ async def test_action(hass, enable_custom_integrations): == STATE_ALARM_ARMED_HOME ) + hass.bus.async_fire("test_event_arm_vacation") + await hass.async_block_till_done() + assert ( + hass.states.get("alarm_control_panel.alarm_no_arm_code").state + == STATE_ALARM_ARMED_VACATION + ) + hass.bus.async_fire("test_event_arm_night") await hass.async_block_till_done() assert ( diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index b1e2c171cea..d0644562850 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -8,6 +8,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -50,11 +51,13 @@ def calls(hass): (False, const.SUPPORT_ALARM_ARM_AWAY, 0, ["is_armed_away"]), (False, const.SUPPORT_ALARM_ARM_HOME, 0, ["is_armed_home"]), (False, const.SUPPORT_ALARM_ARM_NIGHT, 0, ["is_armed_night"]), + (False, const.SUPPORT_ALARM_ARM_VACATION, 0, ["is_armed_vacation"]), (False, const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, 0, ["is_armed_custom_bypass"]), (True, 0, 0, []), (True, 0, const.SUPPORT_ALARM_ARM_AWAY, ["is_armed_away"]), (True, 0, const.SUPPORT_ALARM_ARM_HOME, ["is_armed_home"]), (True, 0, const.SUPPORT_ALARM_ARM_NIGHT, ["is_armed_night"]), + (True, 0, const.SUPPORT_ALARM_ARM_VACATION, ["is_armed_vacation"]), (True, 0, const.SUPPORT_ALARM_ARM_CUSTOM_BYPASS, ["is_armed_custom_bypass"]), ], ) @@ -212,6 +215,24 @@ async def test_if_state(hass, calls): }, { "trigger": {"platform": "event", "event_type": "test_event6"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "alarm_control_panel.entity", + "type": "is_armed_vacation", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_armed_vacation - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event7"}, "condition": [ { "condition": "device", @@ -238,6 +259,7 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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" @@ -249,6 +271,7 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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" @@ -260,6 +283,7 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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" @@ -271,6 +295,7 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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" @@ -282,10 +307,23 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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" + hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_VACATION) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_event4") + hass.bus.async_fire("test_event5") + 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" + hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_CUSTOM_BYPASS) hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") @@ -293,6 +331,7 @@ async def test_if_state(hass, calls): hass.bus.async_fire("test_event4") hass.bus.async_fire("test_event5") 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_custom_bypass - event - test_event6" + assert len(calls) == 7 + assert calls[6].data["some"] == "is_armed_custom_bypass - event - test_event7" diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 8859915b911..9edda7e98e2 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -9,6 +9,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, @@ -54,7 +55,7 @@ def calls(hass): (False, 0, 0, ["triggered", "disarmed", "arming"]), ( False, - 15, + 47, 0, [ "triggered", @@ -63,13 +64,14 @@ def calls(hass): "armed_home", "armed_away", "armed_night", + "armed_vacation", ], ), (True, 0, 0, ["triggered", "disarmed", "arming"]), ( True, 0, - 15, + 47, [ "triggered", "disarmed", @@ -77,6 +79,7 @@ def calls(hass): "armed_home", "armed_away", "armed_night", + "armed_vacation", ], ), ], @@ -256,6 +259,25 @@ async def test_if_fires_on_state_change(hass, calls): }, }, }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "alarm_control_panel.entity", + "type": "armed_vacation", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "armed_vacation - {{ trigger.platform}} - " + "{{ trigger.entity_id}} - {{ trigger.from_state.state}} - " + "{{ trigger.to_state.state}} - {{ trigger.for }}" + ) + }, + }, + }, ] }, ) @@ -305,6 +327,15 @@ async def test_if_fires_on_state_change(hass, calls): == "armed_night - device - alarm_control_panel.entity - armed_away - armed_night - None" ) + # Fake that the entity is armed vacation. + hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_VACATION) + await hass.async_block_till_done() + assert len(calls) == 6 + assert ( + calls[5].data["some"] + == "armed_vacation - device - alarm_control_panel.entity - armed_night - armed_vacation - None" + ) + async def test_if_fires_on_state_change_with_for(hass, calls): """Test for triggers firing with delay.""" diff --git a/tests/components/alarm_control_panel/test_reproduce_state.py b/tests/components/alarm_control_panel/test_reproduce_state.py index 686b281bff8..0f87e2206ac 100644 --- a/tests/components/alarm_control_panel/test_reproduce_state.py +++ b/tests/components/alarm_control_panel/test_reproduce_state.py @@ -4,12 +4,14 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_CUSTOM_BYPASS, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_NIGHT, + SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -34,6 +36,9 @@ async def test_reproducing_states(hass, caplog): hass.states.async_set( "alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT, {} ) + hass.states.async_set( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION, {} + ) hass.states.async_set( "alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED, {} ) @@ -53,6 +58,9 @@ async def test_reproducing_states(hass, caplog): arm_night_calls = async_mock_service( hass, "alarm_control_panel", SERVICE_ALARM_ARM_NIGHT ) + arm_vacation_calls = async_mock_service( + hass, "alarm_control_panel", SERVICE_ALARM_ARM_VACATION + ) disarm_calls = async_mock_service(hass, "alarm_control_panel", SERVICE_ALARM_DISARM) trigger_calls = async_mock_service( hass, "alarm_control_panel", SERVICE_ALARM_TRIGGER @@ -68,6 +76,9 @@ async def test_reproducing_states(hass, caplog): ), State("alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_HOME), State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_NIGHT), + State( + "alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_VACATION + ), State("alarm_control_panel.entity_disarmed", STATE_ALARM_DISARMED), State("alarm_control_panel.entity_triggered", STATE_ALARM_TRIGGERED), ] @@ -77,6 +88,7 @@ async def test_reproducing_states(hass, caplog): assert len(arm_custom_bypass_calls) == 0 assert len(arm_home_calls) == 0 assert len(arm_night_calls) == 0 + assert len(arm_vacation_calls) == 0 assert len(disarm_calls) == 0 assert len(trigger_calls) == 0 @@ -90,6 +102,7 @@ async def test_reproducing_states(hass, caplog): assert len(arm_custom_bypass_calls) == 0 assert len(arm_home_calls) == 0 assert len(arm_night_calls) == 0 + assert len(arm_vacation_calls) == 0 assert len(disarm_calls) == 0 assert len(trigger_calls) == 0 @@ -104,7 +117,8 @@ async def test_reproducing_states(hass, caplog): "alarm_control_panel.entity_armed_home", STATE_ALARM_ARMED_CUSTOM_BYPASS ), State("alarm_control_panel.entity_armed_night", STATE_ALARM_ARMED_HOME), - State("alarm_control_panel.entity_disarmed", STATE_ALARM_ARMED_NIGHT), + State("alarm_control_panel.entity_armed_vacation", STATE_ALARM_ARMED_NIGHT), + State("alarm_control_panel.entity_disarmed", STATE_ALARM_ARMED_VACATION), State("alarm_control_panel.entity_triggered", STATE_ALARM_DISARMED), # Should not raise State("alarm_control_panel.non_existing", "on"), @@ -132,6 +146,12 @@ async def test_reproducing_states(hass, caplog): assert len(arm_night_calls) == 1 assert arm_night_calls[0].domain == "alarm_control_panel" assert arm_night_calls[0].data == { + "entity_id": "alarm_control_panel.entity_armed_vacation" + } + + assert len(arm_vacation_calls) == 1 + assert arm_vacation_calls[0].domain == "alarm_control_panel" + assert arm_vacation_calls[0].data == { "entity_id": "alarm_control_panel.entity_disarmed" } diff --git a/tests/components/alarmdecoder/test_config_flow.py b/tests/components/alarmdecoder/test_config_flow.py index 2ab965023bd..8a2aae48f9b 100644 --- a/tests/components/alarmdecoder/test_config_flow.py +++ b/tests/components/alarmdecoder/test_config_flow.py @@ -126,6 +126,16 @@ async def test_setup_connection_error(hass: HomeAssistant): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "cannot_connect"} + with patch( + "homeassistant.components.alarmdecoder.config_flow.AdExt.open", + side_effect=Exception, + ), patch("homeassistant.components.alarmdecoder.config_flow.AdExt.close"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], connection_settings + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + async def test_options_arm_flow(hass: HomeAssistant): """Test arm options flow.""" diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 92951d4a0e7..dc93ed6d805 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.alexa import smart_home from homeassistant.components.alexa.errors import UnsupportedProperty from homeassistant.components.climate import const as climate +from homeassistant.components.lock import STATE_JAMMED, STATE_LOCKING, STATE_UNLOCKING from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, @@ -227,17 +228,29 @@ async def test_report_lock_state(hass): """Test LockController implements lockState property.""" hass.states.async_set("lock.locked", STATE_LOCKED, {}) hass.states.async_set("lock.unlocked", STATE_UNLOCKED, {}) + hass.states.async_set("lock.unlocking", STATE_UNLOCKING, {}) + hass.states.async_set("lock.locking", STATE_LOCKING, {}) + hass.states.async_set("lock.jammed", STATE_JAMMED, {}) hass.states.async_set("lock.unknown", STATE_UNKNOWN, {}) properties = await reported_properties(hass, "lock.locked") properties.assert_equal("Alexa.LockController", "lockState", "LOCKED") + properties = await reported_properties(hass, "lock.unlocking") + properties.assert_equal("Alexa.LockController", "lockState", "LOCKED") + properties = await reported_properties(hass, "lock.unlocked") properties.assert_equal("Alexa.LockController", "lockState", "UNLOCKED") + properties = await reported_properties(hass, "lock.locking") + properties.assert_equal("Alexa.LockController", "lockState", "UNLOCKED") + properties = await reported_properties(hass, "lock.unknown") properties.assert_equal("Alexa.LockController", "lockState", "JAMMED") + properties = await reported_properties(hass, "lock.jammed") + properties.assert_equal("Alexa.LockController", "lockState", "JAMMED") + @pytest.mark.parametrize( "supported_color_modes", [["brightness"], ["hs"], ["color_temp"]] diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py index a64b7761338..806d31b5386 100644 --- a/tests/components/ambient_station/test_config_flow.py +++ b/tests/components/ambient_station/test_config_flow.py @@ -83,27 +83,6 @@ async def test_show_form(hass): assert result["step_id"] == "user" -@pytest.mark.parametrize( - "get_devices_response", - [mock_coro(return_value=json.loads(load_fixture("ambient_devices.json")))], -) -async def test_step_import(hass, mock_aioambient): - """Test that the import step works.""" - conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - - flow = config_flow.AmbientStationFlowHandler() - flow.hass = hass - flow.context = {"source": SOURCE_USER} - - result = await flow.async_step_import(import_config=conf) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "67890fghij67" - assert result["data"] == { - CONF_API_KEY: "12345abcde12345abcde", - CONF_APP_KEY: "67890fghij67890fghij", - } - - @pytest.mark.parametrize( "get_devices_response", [mock_coro(return_value=json.loads(load_fixture("ambient_devices.json")))], diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index ee67a7e3935..a781cb4c662 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -424,3 +424,72 @@ async def test_nightly_endpoint(hass, aioclient_mock): payload = aioclient_mock.mock_calls[0] assert str(payload[1]) == ANALYTICS_ENDPOINT_URL + + +async def test_send_with_no_energy(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = False + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert "energy" not in postdata + + +async def test_send_with_no_energy_config(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = False + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert not postdata["energy"]["configured"] + + +async def test_send_with_energy_config(hass, aioclient_mock): + """Test send base prefrences are defined.""" + aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) + analytics = Analytics(hass) + + await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True}) + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + + with patch("uuid.UUID.hex", new_callable=PropertyMock) as hex, patch( + "homeassistant.components.analytics.analytics.HA_VERSION", MOCK_VERSION + ), patch( + "homeassistant.components.analytics.analytics.energy_is_configured", AsyncMock() + ) as energy_is_configured: + energy_is_configured.return_value = True + hex.return_value = MOCK_UUID + await analytics.send_analytics() + + postdata = aioclient_mock.mock_calls[-1][2] + + assert postdata["energy"]["configured"] diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py index 05a070aada2..58609da6d6a 100644 --- a/tests/components/arcam_fmj/test_media_player.py +++ b/tests/components/arcam_fmj/test_media_player.py @@ -199,9 +199,9 @@ async def test_sound_mode(player, state, mode, mode_sel, mode_2ch, mode_mch): async def test_sound_mode_list(player, state): """Test sound mode list.""" player._get_2ch = Mock(return_value=True) # pylint: disable=W0212 - assert sorted(player.sound_mode_list) == sorted([x.name for x in DecodeMode2CH]) + assert sorted(player.sound_mode_list) == sorted(x.name for x in DecodeMode2CH) player._get_2ch = Mock(return_value=False) # pylint: disable=W0212 - assert sorted(player.sound_mode_list) == sorted([x.name for x in DecodeModeMCH]) + assert sorted(player.sound_mode_list) == sorted(x.name for x in DecodeModeMCH) async def test_sound_mode_zone_x(player, state): diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 5b3c163780f..9d1c34d917a 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -2,9 +2,16 @@ import datetime from unittest.mock import Mock +from aiohttp import ClientResponseError +import pytest from yalexs.pubnub_async import AugustPubNub -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, +) from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, @@ -59,6 +66,44 @@ async def test_lock_changed_by(hass): ) +async def test_state_locking(hass): + """Test creation of a lock with doorsense and bridge that is locking.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.locking.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKING + + +async def test_state_unlocking(hass): + """Test creation of a lock with doorsense and bridge that is unlocking.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.unlocking.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING + + +async def test_state_jammed(hass): + """Test creation of a lock with doorsense and bridge that is jammed.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.jammed.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_JAMMED + + async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) @@ -109,6 +154,74 @@ async def test_one_lock_operation(hass): ) +async def test_lock_jammed(hass): + """Test lock gets jammed on unlock.""" + + def _unlock_return_activities_side_effect(access_token, device_id): + raise ClientResponseError(None, None, status=531) + + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + await _create_august_with_devices( + hass, + [lock_one], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True + ) + await hass.async_block_till_done() + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_JAMMED + + +async def test_lock_throws_exception_on_unknown_status_code(hass): + """Test lock throws exception.""" + + def _unlock_return_activities_side_effect(access_token, device_id): + raise ClientResponseError(None, None, status=500) + + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + await _create_august_with_devices( + hass, + [lock_one], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) + + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} + with pytest.raises(ClientResponseError): + assert 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): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_lock_from_fixture( @@ -177,14 +290,16 @@ async def test_lock_update_via_pubnub(hass): ) await hass.async_block_till_done() + await hass.async_block_till_done() + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING pubnub.message( pubnub, Mock( channel=lock_one.pubsub_channel, - timetoken=dt_util.utcnow().timestamp() * 10000000, + timetoken=(dt_util.utcnow().timestamp() + 1) * 10000000, message={ "status": "kAugLockState_Locking", }, @@ -192,44 +307,48 @@ async def test_lock_update_via_pubnub(hass): ) await hass.async_block_till_done() + await hass.async_block_till_done() + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING pubnub.connected = True async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(seconds=30)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING # Ensure pubnub status is always preserved async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=2)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKING pubnub.message( pubnub, Mock( channel=lock_one.pubsub_channel, - timetoken=dt_util.utcnow().timestamp() * 10000000, + timetoken=(dt_util.utcnow().timestamp() + 2) * 10000000, message={ "status": "kAugLockState_Unlocking", }, ), ) await hass.async_block_till_done() + await hass.async_block_till_done() + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(hours=4)) await hass.async_block_till_done() lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_online_with_doorsense_name.state == STATE_UNLOCKED + assert lock_online_with_doorsense_name.state == STATE_UNLOCKING await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 80fe5c52abc..214b2ea20e8 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1184,6 +1184,7 @@ async def test_automation_variables(hass, caplog): "variables": { "test_var": "defined_in_config", "event_type": "{{ trigger.event.event_type }}", + "this_variables": "{{this.entity_id}}", }, "trigger": {"platform": "event", "event_type": "test_event"}, "action": { @@ -1191,6 +1192,8 @@ async def test_automation_variables(hass, caplog): "data": { "value": "{{ test_var }}", "event_type": "{{ event_type }}", + "this_template": "{{this.entity_id}}", + "this_variables": "{{this_variables}}", }, }, }, @@ -1224,6 +1227,11 @@ async def test_automation_variables(hass, caplog): assert len(calls) == 1 assert calls[0].data["value"] == "defined_in_config" assert calls[0].data["event_type"] == "test_event" + # Verify this available to all templates + assert calls[0].data.get("this_template") == "automation.automation_0" + # Verify this available during variables rendering + assert calls[0].data.get("this_variables") == "automation.automation_0" + assert "Error rendering variables" not in caplog.text hass.bus.async_fire("test_event_2") await hass.async_block_till_done() @@ -1276,6 +1284,7 @@ async def test_automation_trigger_variables(hass, caplog): }, "trigger_variables": { "test_var": "defined_in_config", + "this_trigger_variables": "{{this.entity_id}}", }, "trigger": {"platform": "event", "event_type": "test_event_2"}, "action": { @@ -1283,6 +1292,8 @@ async def test_automation_trigger_variables(hass, caplog): "data": { "value": "{{ test_var }}", "event_type": "{{ event_type }}", + "this_template": "{{this.entity_id}}", + "this_trigger_variables": "{{this_trigger_variables}}", }, }, }, @@ -1300,7 +1311,10 @@ async def test_automation_trigger_variables(hass, caplog): assert len(calls) == 2 assert calls[1].data["value"] == "overridden_in_config" assert calls[1].data["event_type"] == "test_event_2" - + # Verify this available to all templates + assert calls[1].data.get("this_template") == "automation.automation_1" + # Verify this available during trigger variables rendering + assert calls[1].data.get("this_trigger_variables") == "automation.automation_1" assert "Error rendering variables" not in caplog.text @@ -1332,6 +1346,35 @@ async def test_automation_bad_trigger_variables(hass, caplog): assert len(calls) == 0 +async def test_automation_this_var_always(hass, caplog): + """Test automation always has reference to this, even with no variable or trigger variables configured.""" + calls = async_mock_service(hass, "test", "automation") + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": { + "service": "test.automation", + "data": { + "this_template": "{{this.entity_id}}", + }, + }, + }, + ] + }, + ) + hass.bus.async_fire("test_event") + await hass.async_block_till_done() + assert len(calls) == 1 + # Verify this available to all templates + assert calls[0].data.get("this_template") == "automation.automation_0" + assert "Error rendering variables" not in caplog.text + + async def test_blueprint_automation(hass, calls): """Test blueprint automation.""" assert await async_setup_component( diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index c75814aabc3..0e760b899c1 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -617,9 +617,13 @@ async def test_tls_assets_writer(hass): } with patch("os.mkdir"), patch("builtins.open", mock_open()) as mocked_file: write_tls_asset(hass, CONF_SHC_CERT, assets["cert"]) - mocked_file.assert_called_with(hass.config.path(DOMAIN, CONF_SHC_CERT), "w") + mocked_file.assert_called_with( + hass.config.path(DOMAIN, CONF_SHC_CERT), "w", encoding="utf8" + ) mocked_file().write.assert_called_with("content_cert") write_tls_asset(hass, CONF_SHC_KEY, assets["key"]) - mocked_file.assert_called_with(hass.config.path(DOMAIN, CONF_SHC_KEY), "w") + mocked_file.assert_called_with( + hass.config.path(DOMAIN, CONF_SHC_KEY), "w", encoding="utf8" + ) mocked_file().write.assert_called_with("content_key") diff --git a/tests/components/buienradar/test_config_flow.py b/tests/components/buienradar/test_config_flow.py index b8abefec70a..828101bf77e 100644 --- a/tests/components/buienradar/test_config_flow.py +++ b/tests/components/buienradar/test_config_flow.py @@ -66,34 +66,6 @@ async def test_config_flow_already_configured_weather(hass): assert result["reason"] == "already_configured" -async def test_import_camera(hass): - """Test import of camera.""" - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == f"{TEST_LATITUDE},{TEST_LONGITUDE}" - assert result["data"] == { - CONF_LATITUDE: TEST_LATITUDE, - CONF_LONGITUDE: TEST_LONGITUDE, - } - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_LATITUDE: TEST_LATITUDE, CONF_LONGITUDE: TEST_LONGITUDE}, - ) - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - async def test_options_flow(hass): """Test options flow.""" entry = MockConfigEntry( diff --git a/tests/components/buienradar/test_init.py b/tests/components/buienradar/test_init.py index 0c25fcc1886..568340a0e09 100644 --- a/tests/components/buienradar/test_init.py +++ b/tests/components/buienradar/test_init.py @@ -1,11 +1,7 @@ """Tests for the buienradar component.""" -from unittest.mock import patch - -from homeassistant import setup from homeassistant.components.buienradar.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.helpers.entity_registry import async_get_registry from tests.common import MockConfigEntry @@ -13,91 +9,6 @@ TEST_LATITUDE = 51.5288504 TEST_LONGITUDE = 5.4002156 -async def test_import_all(hass): - """Test import of all platforms.""" - config = { - "weather 1": [{"platform": "buienradar", "name": "test1"}], - "sensor 1": [{"platform": "buienradar", "timeframe": 30, "name": "test2"}], - "camera 1": [ - { - "platform": "buienradar", - "country_code": "BE", - "delta": 300, - "name": "test3", - } - ], - } - - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ): - await setup.async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - conf_entries = hass.config_entries.async_entries(DOMAIN) - - assert len(conf_entries) == 1 - - entry = conf_entries[0] - - assert entry.state is ConfigEntryState.LOADED - assert entry.data == { - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "timeframe": 30, - "country_code": "BE", - "delta": 300, - "name": "test2", - } - - -async def test_import_camera(hass): - """Test import of camera platform.""" - entity_registry = await async_get_registry(hass) - entity_registry.async_get_or_create( - domain="camera", - platform="buienradar", - unique_id="512_NL", - original_name="test_name", - ) - await hass.async_block_till_done() - - config = { - "camera 1": [{"platform": "buienradar", "country_code": "NL", "dimension": 512}] - } - - with patch( - "homeassistant.components.buienradar.async_setup_entry", return_value=True - ): - await setup.async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - - conf_entries = hass.config_entries.async_entries(DOMAIN) - - assert len(conf_entries) == 1 - - entry = conf_entries[0] - - assert entry.state is ConfigEntryState.LOADED - assert entry.data == { - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "timeframe": 60, - "country_code": "NL", - "delta": 600, - "name": "Buienradar", - } - - entity_id = entity_registry.async_get_entity_id( - "camera", - "buienradar", - f"{hass.config.latitude:2.6f}{hass.config.longitude:2.6f}", - ) - assert entity_id - entity = entity_registry.async_get(entity_id) - assert entity.original_name == "test_name" - - async def test_load_unload(aioclient_mock, hass): """Test options flow.""" entry = MockConfigEntry( diff --git a/tests/components/climacell/test_const.py b/tests/components/climacell/test_const.py new file mode 100644 index 00000000000..2719426a7a0 --- /dev/null +++ b/tests/components/climacell/test_const.py @@ -0,0 +1,14 @@ +"""Tests for ClimaCell const.""" +import pytest + +from homeassistant.components.climacell.const import ClimaCellSensorEntityDescription +from homeassistant.const import TEMP_FAHRENHEIT + + +async def test_post_init(): + """Test post initiailization check for ClimaCellSensorEntityDescription.""" + + with pytest.raises(RuntimeError): + ClimaCellSensorEntityDescription( + key="a", name="b", unit_imperial=TEMP_FAHRENHEIT + ) diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py index bdcf0441b9f..af1b14299ae 100644 --- a/tests/components/climate/test_reproduce_state.py +++ b/tests/components/climate/test_reproduce_state.py @@ -117,3 +117,57 @@ async def test_attribute(hass, service, attribute): assert len(calls_1) == 1 assert calls_1[0].data == {"entity_id": ENTITY_1, attribute: value} + + +async def test_attribute_partial_temperature(hass): + """Test that service call ignores null attributes.""" + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_SET_TEMPERATURE) + + await async_reproduce_states( + hass, + [ + State( + ENTITY_1, + None, + { + ATTR_TEMPERATURE: 23.1, + ATTR_TARGET_TEMP_HIGH: None, + ATTR_TARGET_TEMP_LOW: None, + }, + ) + ], + ) + + await hass.async_block_till_done() + + assert len(calls_1) == 1 + assert calls_1[0].data == {"entity_id": ENTITY_1, ATTR_TEMPERATURE: 23.1} + + +async def test_attribute_partial_high_low_temperature(hass): + """Test that service call ignores null attributes.""" + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_SET_TEMPERATURE) + + await async_reproduce_states( + hass, + [ + State( + ENTITY_1, + None, + { + ATTR_TEMPERATURE: None, + ATTR_TARGET_TEMP_HIGH: 30.1, + ATTR_TARGET_TEMP_LOW: 20.2, + }, + ) + ], + ) + + await hass.async_block_till_done() + + assert len(calls_1) == 1 + assert calls_1[0].data == { + "entity_id": ENTITY_1, + ATTR_TARGET_TEMP_HIGH: 30.1, + ATTR_TARGET_TEMP_LOW: 20.2, + } diff --git a/tests/components/co2signal/__init__.py b/tests/components/co2signal/__init__.py new file mode 100644 index 00000000000..1f3d6a83c05 --- /dev/null +++ b/tests/components/co2signal/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the CO2 Signal integration.""" + +VALID_PAYLOAD = { + "status": "ok", + "countryCode": "FR", + "data": { + "carbonIntensity": 45.98623190095805, + "fossilFuelPercentage": 5.461182741937103, + }, + "units": {"carbonIntensity": "gCO2eq/kWh"}, +} diff --git a/tests/components/co2signal/test_config_flow.py b/tests/components/co2signal/test_config_flow.py new file mode 100644 index 00000000000..668c72e9b04 --- /dev/null +++ b/tests/components/co2signal/test_config_flow.py @@ -0,0 +1,300 @@ +"""Test the CO2 Signal config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.co2signal import DOMAIN, config_flow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM +from homeassistant.setup import async_setup_component + +from . import VALID_PAYLOAD + +from tests.common import MockConfigEntry + + +async def test_form_home(hass: HomeAssistant) -> None: + """Test we get the form.""" + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + "homeassistant.components.co2signal.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "CO2 Signal" + assert result2["data"] == { + "api_key": "api_key", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_coordinates(hass: HomeAssistant) -> None: + """Test we get the form.""" + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_SPECIFY_COORDINATES, + "api_key": "api_key", + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + "homeassistant.components.co2signal.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "latitude": 12.3, + "longitude": 45.6, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "12.3, 45.6" + assert result3["data"] == { + "latitude": 12.3, + "longitude": 45.6, + "api_key": "api_key", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_country(hass: HomeAssistant) -> None: + """Test we get the form.""" + 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"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_SPECIFY_COUNTRY, + "api_key": "api_key", + }, + ) + assert result2["type"] == RESULT_TYPE_FORM + + with patch("CO2Signal.get_latest", return_value=VALID_PAYLOAD,), patch( + "homeassistant.components.co2signal.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "country_code": "fr", + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["title"] == "fr" + assert result3["data"] == { + "country_code": "fr", + "api_key": "api_key", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "err_str,err_code", + [ + ("Invalid authentication credentials", "invalid_auth"), + ("API rate limit exceeded.", "api_ratelimit"), + ("Something else", "unknown"), + ], +) +async def test_form_error_handling(hass: HomeAssistant, err_str, err_code) -> None: + """Test we handle expected errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "CO2Signal.get_latest", + side_effect=ValueError(err_str), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": err_code} + + +async def test_form_error_unexpected_error(hass: HomeAssistant) -> None: + """Test we handle unexpected error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "CO2Signal.get_latest", + side_effect=Exception("Boom"), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_error_unexpected_data(hass: HomeAssistant) -> None: + """Test we handle unexpected data.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "CO2Signal.get_latest", + return_value={"status": "error"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "location": config_flow.TYPE_USE_HOME, + "api_key": "api_key", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_import(hass: HomeAssistant) -> None: + """Test we import correctly.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ): + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "co2signal", "token": "1234"}} + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries("co2signal")) == 1 + + state = hass.states.get("sensor.co2_intensity") + assert state is not None + assert state.state == "45.99" + assert state.name == "CO2 intensity" + assert state.attributes["unit_of_measurement"] == "gCO2eq/kWh" + assert state.attributes["country_code"] == "FR" + + state = hass.states.get("sensor.grid_fossil_fuel_percentage") + assert state is not None + assert state.state == "5.46" + assert state.name == "Grid fossil fuel percentage" + assert state.attributes["unit_of_measurement"] == "%" + assert state.attributes["country_code"] == "FR" + + +async def test_import_abort_existing_home(hass: HomeAssistant) -> None: + """Test we abort if home entry found.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + MockConfigEntry(domain="co2signal", data={"api_key": "abcd"}).add_to_hass(hass) + + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ): + assert await async_setup_component( + hass, "sensor", {"sensor": {"platform": "co2signal", "token": "1234"}} + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries("co2signal")) == 1 + + +async def test_import_abort_existing_country(hass: HomeAssistant) -> None: + """Test we abort if existing country found.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + MockConfigEntry( + domain="co2signal", data={"api_key": "abcd", "country_code": "nl"} + ).add_to_hass(hass) + + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ): + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "co2signal", + "token": "1234", + "country_code": "nl", + } + }, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries("co2signal")) == 1 + + +async def test_import_abort_existing_coordinates(hass: HomeAssistant) -> None: + """Test we abort if existing coordinates found.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + MockConfigEntry( + domain="co2signal", data={"api_key": "abcd", "latitude": 1, "longitude": 2} + ).add_to_hass(hass) + + with patch( + "CO2Signal.get_latest", + return_value=VALID_PAYLOAD, + ): + assert await async_setup_component( + hass, + "sensor", + { + "sensor": { + "platform": "co2signal", + "token": "1234", + "latitude": 1, + "longitude": 2, + } + }, + ) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries("co2signal")) == 1 diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 864ebc18701..7d36d0be9a7 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -9,13 +9,6 @@ BAD_CURRENCY = "ETH" BAD_EXCHANGE_RATE = "ETH" MOCK_ACCOUNTS_RESPONSE = [ - { - "balance": {"amount": "13.38", "currency": GOOD_CURRENCY_3}, - "currency": GOOD_CURRENCY_3, - "id": "ABCDEF", - "name": "BTC Wallet", - "native_balance": {"amount": "15.02", "currency": GOOD_CURRENCY_2}, - }, { "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, "currency": GOOD_CURRENCY, diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index 4c7b6c13333..d153cecc249 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -19,14 +19,7 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import ( - BAD_CURRENCY, - BAD_EXCHANGE_RATE, - GOOD_CURRENCY, - GOOD_CURRENCY_2, - GOOD_EXCHNAGE_RATE, - GOOD_EXCHNAGE_RATE_2, -) +from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHNAGE_RATE from tests.common import MockConfigEntry @@ -144,19 +137,8 @@ async def test_form_catch_all_exception(hass): assert result2["errors"] == {"base": "unknown"} -async def test_option_good_account_currency(hass): +async def test_option_form(hass): """Test we handle a good wallet currency option.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - title="Test User", - data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, - options={ - CONF_CURRENCIES: [GOOD_CURRENCY_2], - CONF_EXCHANGE_RATES: [], - }, - ) - config_entry.add_to_hass(hass) with patch( "coinbase.wallet.client.Client.get_current_user", @@ -166,8 +148,11 @@ async def test_option_good_account_currency(hass): ), patch( "coinbase.wallet.client.Client.get_exchange_rates", return_value=mock_get_exchange_rates(), - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) + ), patch( + "homeassistant.components.coinbase.update_listener" + ) as mock_update_listener: + + config_entry = await init_mock_coinbase(hass) await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() @@ -175,10 +160,12 @@ async def test_option_good_account_currency(hass): result["flow_id"], user_input={ CONF_CURRENCIES: [GOOD_CURRENCY], - CONF_EXCHANGE_RATES: [], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], }, ) - assert result2["type"] == "create_entry" + assert result2["type"] == "create_entry" + await hass.async_block_till_done() + assert len(mock_update_listener.mock_calls) == 1 async def test_form_bad_account_currency(hass): @@ -207,43 +194,6 @@ async def test_form_bad_account_currency(hass): assert result2["errors"] == {"base": "currency_unavaliable"} -async def test_option_good_exchange_rate(hass): - """Test we handle a good exchange rate option.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - title="Test User", - data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, - options={ - CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE_2], - }, - ) - config_entry.add_to_hass(hass) - - with patch( - "coinbase.wallet.client.Client.get_current_user", - return_value=mock_get_current_user(), - ), patch( - "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts - ), patch( - "coinbase.wallet.client.Client.get_exchange_rates", - return_value=mock_get_exchange_rates(), - ): - 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) - await hass.async_block_till_done() - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], - }, - ) - assert result2["type"] == "create_entry" - - async def test_form_bad_exchange_rate(hass): """Test we handle a bad exchange rate.""" with patch( diff --git a/tests/components/coinbase/test_init.py b/tests/components/coinbase/test_init.py index 612519b1cee..36f0ff95472 100644 --- a/tests/components/coinbase/test_init.py +++ b/tests/components/coinbase/test_init.py @@ -9,6 +9,8 @@ from homeassistant.components.coinbase.const import ( DOMAIN, ) from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from .common import ( @@ -78,3 +80,81 @@ async def test_unload_entry(hass): assert entry.state == config_entries.ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +async def test_option_updates(hass: HomeAssistant): + """Test handling option updates.""" + + with patch( + "coinbase.wallet.client.Client.get_current_user", + return_value=mock_get_current_user(), + ), patch( + "coinbase.wallet.client.Client.get_accounts", new=mocked_get_accounts + ), patch( + "coinbase.wallet.client.Client.get_exchange_rates", + return_value=mock_get_exchange_rates(), + ): + config_entry = await init_mock_coinbase(hass) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [GOOD_CURRENCY, GOOD_CURRENCY_2], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2], + }, + ) + await hass.async_block_till_done() + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + assert len(entities) == 4 + currencies = [ + entity.unique_id.split("-")[-1] + for entity in entities + if "wallet" in entity.unique_id + ] + + rates = [ + entity.unique_id.split("-")[-1] + for entity in entities + if "xe" in entity.unique_id + ] + + assert currencies == [GOOD_CURRENCY, GOOD_CURRENCY_2] + assert rates == [GOOD_EXCHNAGE_RATE, GOOD_EXCHNAGE_RATE_2] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCIES: [GOOD_CURRENCY], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE], + }, + ) + await hass.async_block_till_done() + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + assert len(entities) == 2 + currencies = [ + entity.unique_id.split("-")[-1] + for entity in entities + if "wallet" in entity.unique_id + ] + + rates = [ + entity.unique_id.split("-")[-1] + for entity in entities + if "xe" in entity.unique_id + ] + + assert currencies == [GOOD_CURRENCY] + assert rates == [GOOD_EXCHNAGE_RATE] diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 738b1183c14..9c86a3f2d1b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -1,4 +1,4 @@ -"""Test hassbian config.""" +"""Test core config.""" from unittest.mock import patch import pytest @@ -60,6 +60,7 @@ async def test_websocket_core_update(hass, client): assert hass.config.time_zone != "America/New_York" assert hass.config.external_url != "https://www.example.com" assert hass.config.internal_url != "http://example.com" + assert hass.config.currency == "EUR" with patch("homeassistant.util.dt.set_default_time_zone") as mock_set_tz: await client.send_json( @@ -74,6 +75,7 @@ async def test_websocket_core_update(hass, client): "time_zone": "America/New_York", "external_url": "https://www.example.com", "internal_url": "http://example.local", + "currency": "USD", } ) @@ -89,6 +91,7 @@ async def test_websocket_core_update(hass, client): assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" + assert hass.config.currency == "USD" 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") @@ -144,6 +147,7 @@ async def test_detect_config_fail(hass, client): return_value=location.LocationInfo( ip=None, country_code=None, + currency=None, region_code=None, region_name=None, city=None, diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index 04a353cb200..4e10413f14f 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -42,7 +42,7 @@ async def test_list_devices(hass, client, registry): await client.send_json({"id": 5, "type": "config/device_registry/list"}) msg = await client.receive_json() - dev1, dev2 = [entry.pop("id") for entry in msg["result"]] + dev1, dev2 = (entry.pop("id") for entry in msg["result"]) assert msg["result"] == [ { diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index 798a96a43d7..418545f11cf 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -267,6 +267,50 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): # Unsupported events + # Bad action string; string is None + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"action": None}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 1 + + # Bad action string; empty string + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"action": ""}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 1 + + # Bad action string; too few "," + + event_changed_sensor = { + "t": "event", + "e": "changed", + "r": "sensors", + "id": "1", + "state": {"action": "armed_away,1234"}, + } + await mock_deconz_websocket(data=event_changed_sensor) + await hass.async_block_till_done() + + assert len(captured_events) == 1 + + # Bad action string; unsupported command + event_changed_sensor = { "t": "event", "e": "changed", @@ -279,6 +323,8 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): assert len(captured_events) == 1 + # Only care for changes to action + event_changed_sensor = { "t": "event", "e": "changed", diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index d9c4adf1388..7f8bce24d80 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, @@ -118,7 +119,7 @@ async def test_sensors(hass, aioclient_mock, mock_deconz_websocket): consumption_sensor = hass.states.get("sensor.consumption_sensor") assert consumption_sensor.state == "0.002" - assert ATTR_DEVICE_CLASS not in consumption_sensor.attributes + assert consumption_sensor.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_ENERGY assert not hass.states.get("sensor.clip_light_level_sensor") diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index bf8c0ddb63d..15e4e14524d 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -1,4 +1,6 @@ """The tests for the Demo lock platform.""" +import asyncio + import pytest from homeassistant.components.demo import DOMAIN @@ -7,8 +9,11 @@ from homeassistant.components.lock import ( SERVICE_LOCK, SERVICE_OPEN, SERVICE_UNLOCK, + STATE_JAMMED, STATE_LOCKED, + STATE_LOCKING, STATE_UNLOCKED, + STATE_UNLOCKING, ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.setup import async_setup_component @@ -17,6 +22,7 @@ from tests.common import async_mock_service FRONT = "lock.front_door" KITCHEN = "lock.kitchen_door" +POORLY_INSTALLED = "lock.poorly_installed_door" OPENABLE_LOCK = "lock.openable_lock" @@ -35,9 +41,13 @@ async def test_locking(hass): assert state.state == STATE_UNLOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=True + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: KITCHEN}, blocking=False ) + await asyncio.sleep(1) + state = hass.states.get(KITCHEN) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) state = hass.states.get(KITCHEN) assert state.state == STATE_LOCKED @@ -48,17 +58,46 @@ async def test_unlocking(hass): assert state.state == STATE_LOCKED await hass.services.async_call( - LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=True + LOCK_DOMAIN, SERVICE_UNLOCK, {ATTR_ENTITY_ID: FRONT}, blocking=False ) - + await asyncio.sleep(1) + state = hass.states.get(FRONT) + assert state.state == STATE_UNLOCKING + await asyncio.sleep(2) state = hass.states.get(FRONT) assert state.state == STATE_UNLOCKED -async def test_opening(hass): +async def test_jammed_when_locking(hass): + """Test the locking of a lock jams.""" + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_UNLOCKED + + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: POORLY_INSTALLED}, blocking=False + ) + + await asyncio.sleep(1) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_LOCKING + await asyncio.sleep(2) + state = hass.states.get(POORLY_INSTALLED) + assert state.state == STATE_JAMMED + + +async def test_opening_mocked(hass): """Test the opening of a lock.""" calls = async_mock_service(hass, LOCK_DOMAIN, SERVICE_OPEN) await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: OPENABLE_LOCK}, blocking=True ) assert len(calls) == 1 + + +async def test_opening(hass): + """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_siren.py b/tests/components/demo/test_siren.py new file mode 100644 index 00000000000..9af31b53c74 --- /dev/null +++ b/tests/components/demo/test_siren.py @@ -0,0 +1,118 @@ +"""The tests for the demo siren component.""" +from unittest.mock import call, patch + +import pytest + +from homeassistant.components.siren.const import ( + ATTR_AVAILABLE_TONES, + ATTR_TONE, + ATTR_VOLUME_LEVEL, + DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.setup import async_setup_component + +ENTITY_SIREN = "siren.siren" +ENTITY_SIREN_WITH_ALL_FEATURES = "siren.siren_with_all_features" + + +@pytest.fixture(autouse=True) +async def setup_demo_siren(hass): + """Initialize setup demo siren.""" + assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass): + """Test the initial parameters.""" + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + assert ATTR_AVAILABLE_TONES not in state.attributes + + +def test_all_setup_params(hass): + """Test the setup with all parameters.""" + state = hass.states.get(ENTITY_SIREN_WITH_ALL_FEATURES) + assert state.attributes.get(ATTR_AVAILABLE_TONES) == ["fire", "alarm"] + + +async def test_turn_on(hass): + """Test turn on device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + # Test that an invalid tone will raise a ValueError + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_SIREN_WITH_ALL_FEATURES, ATTR_TONE: "invalid_tone"}, + blocking=True, + ) + + +async def test_turn_off(hass): + """Test turn off device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + +async def test_toggle(hass): + """Test toggle device.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: ENTITY_SIREN}, blocking=True + ) + state = hass.states.get(ENTITY_SIREN) + assert state.state == STATE_ON + + +async def test_turn_on_strip_attributes(hass): + """Test attributes are stripped from turn_on service call when not supported.""" + with patch( + "homeassistant.components.demo.siren.DemoSiren.async_turn_on" + ) as svc_call: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_SIREN, ATTR_VOLUME_LEVEL: 1}, + blocking=True, + ) + assert svc_call.called + assert svc_call.call_args_list[0] == call() diff --git a/tests/components/demo/test_switch.py b/tests/components/demo/test_switch.py new file mode 100644 index 00000000000..f35bc14db34 --- /dev/null +++ b/tests/components/demo/test_switch.py @@ -0,0 +1,88 @@ +"""The tests for the demo switch component.""" +import pytest + +from homeassistant.components.demo import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.setup import async_setup_component + +SWITCH_ENTITY_IDS = ["switch.decorative_lights", "switch.ac"] + + +@pytest.fixture(autouse=True) +async def setup_comp(hass): + """Set up demo component.""" + assert await async_setup_component( + hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": DOMAIN}} + ) + await hass.async_block_till_done() + + +@pytest.mark.parametrize("switch_entity_id", SWITCH_ENTITY_IDS) +async def test_turn_on(hass, switch_entity_id): + """Test switch turn on method.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_ON + + +@pytest.mark.parametrize("switch_entity_id", SWITCH_ENTITY_IDS) +async def test_turn_off(hass, switch_entity_id): + """Test switch turn off method.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: switch_entity_id}, + blocking=True, + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("switch_entity_id", SWITCH_ENTITY_IDS) +async def test_turn_off_without_entity_id(hass, switch_entity_id): + """Test switch turn off all switches.""" + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "all"}, blocking=True + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_ON + + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "all"}, blocking=True + ) + + state = hass.states.get(switch_entity_id) + assert state.state == STATE_OFF diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 7c16d067eff..160e6354b8b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -185,12 +185,13 @@ async def test_websocket_get_action_capabilities( "alarm_control_panel", "test", "5678", device_id=device_entry.id ) hass.states.async_set( - "alarm_control_panel.test_5678", "attributes", {"supported_features": 15} + "alarm_control_panel.test_5678", "attributes", {"supported_features": 47} ) expected_capabilities = { "arm_away": {"extra_fields": []}, "arm_home": {"extra_fields": []}, "arm_night": {"extra_fields": []}, + "arm_vacation": {"extra_fields": []}, "disarm": { "extra_fields": [{"name": "code", "optional": True, "type": "string"}] }, @@ -209,7 +210,7 @@ async def test_websocket_get_action_capabilities( actions = msg["result"] id = 2 - assert len(actions) == 5 + assert len(actions) == 6 for action in actions: await client.send_json( { diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index d2ba69d9440..7700d30b1dd 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -1,91 +1,119 @@ """Mocks for tests.""" +from typing import Any from unittest.mock import MagicMock +from devolo_home_control_api.devices.zwave import Zwave +from devolo_home_control_api.homecontrol import HomeControl +from devolo_home_control_api.properties.binary_sensor_property import ( + BinarySensorProperty, +) +from devolo_home_control_api.properties.settings_property import SettingsProperty from devolo_home_control_api.publisher.publisher import Publisher -class BinarySensorPropertyMock: +class BinarySensorPropertyMock(BinarySensorProperty): """devolo Home Control binary sensor mock.""" - element_uid = "Test" - key_count = 1 - sensor_type = "door" - sub_type = "" - state = False + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + self._logger = MagicMock() + self.element_uid = "Test" + self.key_count = 1 + self.sensor_type = "door" + self.sub_type = "" + self.state = False -class SettingsMock: +class SettingsMock(SettingsProperty): """devolo Home Control settings mock.""" - name = "Test" - zone = "Test" + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + self._logger = MagicMock() + self.name = "Test" + self.zone = "Test" -class DeviceMock: +class DeviceMock(Zwave): """devolo Home Control device mock.""" - available = True - brand = "devolo" - name = "Test Device" - uid = "Test" - settings_property = {"general_device_settings": SettingsMock()} - - def is_online(self): - """Mock online state of the device.""" - return DeviceMock.available + def __init__(self) -> None: + """Initialize the mock.""" + self.status = 0 + self.brand = "devolo" + self.name = "Test Device" + self.uid = "Test" + self.settings_property = {"general_device_settings": SettingsMock()} class BinarySensorMock(DeviceMock): """devolo Home Control binary sensor device mock.""" - binary_sensor_property = {"Test": BinarySensorPropertyMock()} + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_sensor_property = {"Test": BinarySensorPropertyMock()} class RemoteControlMock(DeviceMock): """devolo Home Control remote control device mock.""" - remote_control_property = {"Test": BinarySensorPropertyMock()} + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.remote_control_property = {"Test": BinarySensorPropertyMock()} class DisabledBinarySensorMock(DeviceMock): """devolo Home Control disabled binary sensor device mock.""" - binary_sensor_property = {"devolo.WarningBinaryFI:Test": BinarySensorPropertyMock()} + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.binary_sensor_property = { + "devolo.WarningBinaryFI:Test": BinarySensorPropertyMock() + } -class HomeControlMock: +class HomeControlMock(HomeControl): """devolo Home Control gateway mock.""" - binary_sensor_devices = [] - binary_switch_devices = [] - multi_level_sensor_devices = [] - multi_level_switch_devices = [] - devices = {} - publisher = MagicMock() + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + self.devices = {} + self.publisher = MagicMock() - def websocket_disconnect(self): + def websocket_disconnect(self, event: str): """Mock disconnect of the websocket.""" - pass class HomeControlMockBinarySensor(HomeControlMock): """devolo Home Control gateway mock with binary sensor device.""" - binary_sensor_devices = [BinarySensorMock()] - devices = {"Test": BinarySensorMock()} - publisher = Publisher(devices.keys()) - publisher.unregister = MagicMock() + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": BinarySensorMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() class HomeControlMockRemoteControl(HomeControlMock): """devolo Home Control gateway mock with remote control device.""" - devices = {"Test": RemoteControlMock()} - publisher = Publisher(devices.keys()) + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": RemoteControlMock()} + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() class HomeControlMockDisabledBinarySensor(HomeControlMock): """devolo Home Control gateway mock with disabled device.""" - binary_sensor_devices = [DisabledBinarySensorMock()] + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = {"Test": DisabledBinarySensorMock()} diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index 022cd4a1578..32c2e97e7c9 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -9,7 +9,6 @@ from homeassistant.core import HomeAssistant from . import configure_integration from .mocks import ( - DeviceMock, HomeControlMock, HomeControlMockBinarySensor, HomeControlMockDisabledBinarySensor, @@ -21,10 +20,11 @@ from .mocks import ( async def test_binary_sensor(hass: HomeAssistant): """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) - DeviceMock.available = True + test_gateway = HomeControlMockBinarySensor() + test_gateway.devices["Test"].status = 0 with patch( "homeassistant.components.devolo_home_control.HomeControl", - side_effect=[HomeControlMockBinarySensor, HomeControlMock], + side_effect=[test_gateway, HomeControlMock()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -34,13 +34,13 @@ async def test_binary_sensor(hass: HomeAssistant): assert state.state == STATE_OFF # Emulate websocket message: sensor turned on - HomeControlMockBinarySensor.publisher.dispatch("Test", ("Test", True)) + test_gateway.publisher.dispatch("Test", ("Test", True)) await hass.async_block_till_done() assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON # Emulate websocket message: device went offline - DeviceMock.available = False - HomeControlMockBinarySensor.publisher.dispatch("Test", ("Status", False, "status")) + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE @@ -49,10 +49,11 @@ async def test_binary_sensor(hass: HomeAssistant): async def test_remote_control(hass: HomeAssistant): """Test setup and state change of a remote control device.""" entry = configure_integration(hass) - DeviceMock.available = True + test_gateway = HomeControlMockRemoteControl() + test_gateway.devices["Test"].status = 0 with patch( "homeassistant.components.devolo_home_control.HomeControl", - side_effect=[HomeControlMockRemoteControl, HomeControlMock], + side_effect=[test_gateway, HomeControlMock()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -62,18 +63,18 @@ async def test_remote_control(hass: HomeAssistant): assert state.state == STATE_OFF # Emulate websocket message: button pressed - HomeControlMockRemoteControl.publisher.dispatch("Test", ("Test", 1)) + test_gateway.publisher.dispatch("Test", ("Test", 1)) await hass.async_block_till_done() assert hass.states.get(f"{DOMAIN}.test").state == STATE_ON # Emulate websocket message: button released - HomeControlMockRemoteControl.publisher.dispatch("Test", ("Test", 0)) + test_gateway.publisher.dispatch("Test", ("Test", 0)) await hass.async_block_till_done() assert hass.states.get(f"{DOMAIN}.test").state == STATE_OFF # Emulate websocket message: device went offline - DeviceMock.available = False - HomeControlMockRemoteControl.publisher.dispatch("Test", ("Status", False, "status")) + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE @@ -84,7 +85,7 @@ async def test_disabled(hass: HomeAssistant): entry = configure_integration(hass) with patch( "homeassistant.components.devolo_home_control.HomeControl", - side_effect=[HomeControlMockDisabledBinarySensor, HomeControlMock], + side_effect=[HomeControlMockDisabledBinarySensor(), HomeControlMock()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -96,9 +97,10 @@ async def test_disabled(hass: HomeAssistant): async def test_remove_from_hass(hass: HomeAssistant): """Test removing entity.""" entry = configure_integration(hass) + test_gateway = HomeControlMockBinarySensor() with patch( "homeassistant.components.devolo_home_control.HomeControl", - side_effect=[HomeControlMockBinarySensor, HomeControlMock], + side_effect=[test_gateway, HomeControlMock()], ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -109,4 +111,4 @@ async def test_remove_from_hass(hass: HomeAssistant): await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - HomeControlMockBinarySensor.publisher.unregister.assert_called_once() + test_gateway.publisher.unregister.assert_called_once() diff --git a/tests/components/emulated_kasa/test_init.py b/tests/components/emulated_kasa/test_init.py index 60f4f5be1db..d68221d84fd 100644 --- a/tests/components/emulated_kasa/test_init.py +++ b/tests/components/emulated_kasa/test_init.py @@ -217,6 +217,12 @@ async def test_switch_power(hass): SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True ) + hass.states.async_set( + ENTITY_SWITCH, + STATE_ON, + attributes={ATTR_CURRENT_POWER_W: 100, ATTR_FRIENDLY_NAME: "AC"}, + ) + switch = hass.states.get(ENTITY_SWITCH) assert switch.state == STATE_ON power = switch.attributes[ATTR_CURRENT_POWER_W] diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py index 8f256ee4c79..d69df5a1fbe 100644 --- a/tests/components/emulated_roku/test_init.py +++ b/tests/components/emulated_roku/test_init.py @@ -93,7 +93,11 @@ async def test_setup_entry_successful(hass): async def test_unload_entry(hass): """Test being able to unload an entry.""" entry = Mock() - entry.data = {"name": "Emulated Roku Test", "listen_port": 8060} + entry.data = { + "name": "Emulated Roku Test", + "listen_port": 8060, + emulated_roku.CONF_HOST_IP: "1.2.3.5", + } with patch( "homeassistant.components.emulated_roku.binding.EmulatedRokuServer", diff --git a/tests/components/energy/__init__.py b/tests/components/energy/__init__.py new file mode 100644 index 00000000000..ca14c80b951 --- /dev/null +++ b/tests/components/energy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Energy integration.""" diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py new file mode 100644 index 00000000000..978b21e1919 --- /dev/null +++ b/tests/components/energy/test_sensor.py @@ -0,0 +1,297 @@ +"""Test the Energy sensors.""" +import copy +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.components.energy import data +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + STATE_CLASS_MEASUREMENT, +) +from homeassistant.components.sensor.recorder import compile_statistics +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_MONETARY, + ENERGY_KILO_WATT_HOUR, + ENERGY_WATT_HOUR, +) +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_init_recorder_component +from tests.components.recorder.common import async_wait_recording_done_without_instance + + +async def setup_integration(hass): + """Set up the integration.""" + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + await hass.async_block_till_done() + + +async def test_cost_sensor_no_states(hass, hass_storage) -> None: + """Test sensors are created.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "foo", + "entity_energy_from": "foo", + "stat_cost": None, + "entity_energy_price": "bar", + "number_energy_price": None, + } + ], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + await setup_integration(hass) + # TODO: No states, should the cost entity refuse to setup? + + +@pytest.mark.parametrize("initial_energy,initial_cost", [(0, "0.0"), (None, "unknown")]) +@pytest.mark.parametrize( + "price_entity,fixed_price", [("sensor.energy_price", None), (None, 1)] +) +@pytest.mark.parametrize( + "usage_sensor_entity_id,cost_sensor_entity_id,flow_type", + [ + ("sensor.energy_consumption", "sensor.energy_consumption_cost", "flow_from"), + ( + "sensor.energy_production", + "sensor.energy_production_compensation", + "flow_to", + ), + ], +) +async def test_cost_sensor_price_entity( + hass, + hass_storage, + hass_ws_client, + initial_energy, + initial_cost, + price_entity, + fixed_price, + usage_sensor_entity_id, + cost_sensor_entity_id, + flow_type, +) -> None: + """Test energy cost price from sensor entity.""" + + def _compile_statistics(_): + return compile_statistics(hass, now, now + timedelta(seconds=1)) + + await async_init_recorder_component(hass) + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_from" + else [], + "flow_to": [ + { + "stat_energy_to": "sensor.energy_production", + "entity_energy_to": "sensor.energy_production", + "stat_compensation": None, + "entity_energy_price": price_entity, + "number_energy_price": fixed_price, + } + ] + if flow_type == "flow_to" + else [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + + # Optionally initialize dependent entities + if initial_energy is not None: + hass.states.async_set( + usage_sensor_entity_id, + initial_energy, + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + hass.states.async_set("sensor.energy_price", "1") + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == initial_cost + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + if initial_cost != "unknown": + assert state.attributes[ATTR_LAST_RESET] == now.isoformat() + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # Optional late setup of dependent entities + if initial_energy is None: + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + usage_sensor_entity_id, + "0", + { + "last_reset": last_reset, + ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_MONETARY + assert state.attributes[ATTR_LAST_RESET] == now.isoformat() + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "EUR" + + # # Unique ID temp disabled + # # entity_registry = er.async_get(hass) + # # entry = entity_registry.async_get(cost_sensor_entity_id) + # # assert entry.unique_id == "energy_energy_consumption cost" + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 0 EUR + (10-0) kWh * 1 EUR/kWh = 10 EUR + + # Nothing happens when price changes + if price_entity is not None: + hass.states.async_set(price_entity, "2") + await hass.async_block_till_done() + else: + energy_data = copy.deepcopy(energy_data) + energy_data["energy_sources"][0][flow_type][0]["number_energy_price"] = 2 + client = await hass_ws_client(hass) + await client.send_json({"id": 5, "type": "energy/save_prefs", **energy_data}) + msg = await client.receive_json() + assert msg["success"] + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "10.0" # 10 EUR + (10-10) kWh * 2 EUR/kWh = 10 EUR + + # Additional consumption is using the new price + hass.states.async_set( + usage_sensor_entity_id, + "14.5", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "19.0" # 10 EUR + (14.5-10) kWh * 2 EUR/kWh = 19 EUR + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 19.0 + + # Energy sensor is reset, with start point at 4kWh + last_reset = (now + timedelta(seconds=1)).isoformat() + hass.states.async_set( + usage_sensor_entity_id, + "4", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "0.0" # 0 EUR + (4-4) kWh * 2 EUR/kWh = 0 EUR + + # Energy use bumped to 10 kWh + hass.states.async_set( + usage_sensor_entity_id, + "10", + {"last_reset": last_reset, ATTR_UNIT_OF_MEASUREMENT: ENERGY_KILO_WATT_HOUR}, + ) + await hass.async_block_till_done() + state = hass.states.get(cost_sensor_entity_id) + assert state.state == "12.0" # 0 EUR + (10-4) kWh * 2 EUR/kWh = 12 EUR + + # Check generated statistics + await async_wait_recording_done_without_instance(hass) + statistics = await hass.loop.run_in_executor(None, _compile_statistics, hass) + assert cost_sensor_entity_id in statistics + assert statistics[cost_sensor_entity_id]["stat"]["sum"] == 31.0 + + +async def test_cost_sensor_handle_wh(hass, hass_storage) -> None: + """Test energy cost price from sensor entity.""" + energy_data = data.EnergyManager.default_preferences() + energy_data["energy_sources"].append( + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.energy_consumption", + "entity_energy_from": "sensor.energy_consumption", + "stat_cost": None, + "entity_energy_price": None, + "number_energy_price": 0.5, + } + ], + "flow_to": [], + "cost_adjustment_day": 0, + } + ) + + hass_storage[data.STORAGE_KEY] = { + "version": 1, + "data": energy_data, + } + + now = dt_util.utcnow() + last_reset = dt_util.utc_from_timestamp(0).isoformat() + + hass.states.async_set( + "sensor.energy_consumption", + 10000, + {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + ) + + with patch("homeassistant.util.dt.utcnow", return_value=now): + await setup_integration(hass) + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "0.0" + + # Energy use bumped to 10 kWh + hass.states.async_set( + "sensor.energy_consumption", + 20000, + {"last_reset": last_reset, "unit_of_measurement": ENERGY_WATT_HOUR}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_consumption_cost") + assert state.state == "5.0" diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py new file mode 100644 index 00000000000..a14a8d0986e --- /dev/null +++ b/tests/components/energy/test_websocket_api.py @@ -0,0 +1,213 @@ +"""Test the Energy websocket API.""" +import pytest + +from homeassistant.components.energy import data, is_configured +from homeassistant.setup import async_setup_component + +from tests.common import flush_store + + +@pytest.fixture(autouse=True) +async def setup_integration(hass): + """Set up the integration.""" + assert await async_setup_component( + hass, "energy", {"recorder": {"db_url": "sqlite://"}} + ) + + +async def test_get_preferences_no_data(hass, hass_ws_client) -> None: + """Test we get error if no preferences set.""" + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "energy/get_prefs"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"] == {"code": "not_found", "message": "No prefs"} + + +async def test_get_preferences_default(hass, hass_ws_client, hass_storage) -> None: + """Test we get preferences.""" + assert not await is_configured(hass) + manager = await data.async_get_manager(hass) + manager.data = data.EnergyManager.default_preferences() + client = await hass_ws_client(hass) + + assert not await is_configured(hass) + + await client.send_json({"id": 5, "type": "energy/get_prefs"}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == data.EnergyManager.default_preferences() + + +async def test_save_preferences(hass, hass_ws_client, hass_storage) -> None: + """Test we can save preferences.""" + client = await hass_ws_client(hass) + + # Test saving default prefs is also valid. + default_prefs = data.EnergyManager.default_preferences() + + await client.send_json({"id": 5, "type": "energy/save_prefs", **default_prefs}) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert msg["success"] + assert msg["result"] == default_prefs + + new_prefs = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.heat_pump_meter", + "stat_cost": "heat_pump_kwh_cost", + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + { + "stat_energy_from": "sensor.heat_pump_meter_2", + "stat_cost": None, + "entity_energy_from": "sensor.heat_pump_meter_2", + "entity_energy_price": None, + "number_energy_price": 0.20, + }, + ], + "flow_to": [ + { + "stat_energy_to": "sensor.return_to_grid_peak", + "stat_compensation": None, + "entity_energy_to": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + { + "stat_energy_to": "sensor.return_to_grid_offpeak", + "stat_compensation": None, + "entity_energy_to": "sensor.return_to_grid_offpeak", + "entity_energy_price": None, + "number_energy_price": 0.20, + }, + ], + "cost_adjustment_day": 1.2, + }, + { + "type": "solar", + "stat_energy_from": "my_solar_production", + "config_entry_solar_forecast": ["predicted_config_entry"], + }, + ], + "device_consumption": [{"stat_consumption": "some_device_usage"}], + } + + await client.send_json({"id": 6, "type": "energy/save_prefs", **new_prefs}) + + msg = await client.receive_json() + + assert msg["id"] == 6 + assert msg["success"] + assert msg["result"] == new_prefs + + assert data.STORAGE_KEY not in hass_storage, "expected not to be written yet" + + await flush_store((await data.async_get_manager(hass))._store) + + assert hass_storage[data.STORAGE_KEY]["data"] == new_prefs + + assert await is_configured(hass) + + # Verify info reflects data. + await client.send_json({"id": 7, "type": "energy/info"}) + + msg = await client.receive_json() + + assert msg["id"] == 7 + assert msg["success"] + assert msg["result"] == { + "cost_sensors": { + "sensor.heat_pump_meter_2": "sensor.heat_pump_meter_2_cost", + "sensor.return_to_grid_offpeak": "sensor.return_to_grid_offpeak_compensation", + } + } + + # Prefs with limited options + new_prefs_2 = { + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.heat_pump_meter", + "stat_cost": None, + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + } + ], + "flow_to": [], + "cost_adjustment_day": 1.2, + }, + { + "type": "solar", + "stat_energy_from": "my_solar_production", + "config_entry_solar_forecast": None, + }, + ], + } + + await client.send_json({"id": 8, "type": "energy/save_prefs", **new_prefs_2}) + + msg = await client.receive_json() + + assert msg["id"] == 8 + assert msg["success"] + assert msg["result"] == {**new_prefs, **new_prefs_2} + + +async def test_handle_duplicate_from_stat(hass, hass_ws_client) -> None: + """Test we handle duplicate from stats.""" + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "energy/save_prefs", + "energy_sources": [ + { + "type": "grid", + "flow_from": [ + { + "stat_energy_from": "sensor.heat_pump_meter", + "stat_cost": None, + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + { + "stat_energy_from": "sensor.heat_pump_meter", + "stat_cost": None, + "entity_energy_from": None, + "entity_energy_price": None, + "number_energy_price": None, + }, + ], + "flow_to": [], + "cost_adjustment_day": 0, + }, + ], + } + ) + + msg = await client.receive_json() + + assert msg["id"] == 5 + assert not msg["success"] + assert msg["error"]["code"] == "invalid_format" diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 735a02e960c..a5de14d946d 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -48,6 +48,13 @@ def mock_api_connection_error(): yield mock_error +@pytest.fixture(autouse=True) +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch("homeassistant.components.esphome.async_setup_entry", return_value=True): + yield + + async def test_user_connection_works(hass, mock_client): """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index d2db5d9e8a8..9650c6945a6 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -63,7 +63,7 @@ async def test_notify_file(hass, timestamp): full_filename = os.path.join(hass.config.path(), filename) assert m_open.call_count == 1 - assert m_open.call_args == call(full_filename, "a") + assert m_open.call_args == call(full_filename, "a", encoding="utf8") assert m_open.return_value.write.call_count == 2 if not timestamp: diff --git a/tests/components/flipr/__init__.py b/tests/components/flipr/__init__.py new file mode 100644 index 00000000000..26767261866 --- /dev/null +++ b/tests/components/flipr/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flipr integration.""" diff --git a/tests/components/flipr/test_config_flow.py b/tests/components/flipr/test_config_flow.py new file mode 100644 index 00000000000..66410938aab --- /dev/null +++ b/tests/components/flipr/test_config_flow.py @@ -0,0 +1,166 @@ +"""Test the Flipr config flow.""" +from unittest.mock import patch + +import pytest +from requests.exceptions import HTTPError, Timeout + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + + +@pytest.fixture(name="mock_setup") +def mock_setups(): + """Prevent setup.""" + with patch( + "homeassistant.components.flipr.async_setup_entry", + return_value=True, + ): + yield + + +async def test_show_form(hass): + """Test we get the form.""" + 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"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == config_entries.SOURCE_USER + + +async def test_invalid_credential(hass, mock_setup): + """Test invalid credential.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=HTTPError() + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "bad_login", + CONF_PASSWORD: "bad_pass", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_nominal_case(hass, mock_setup): + """Test valid login form.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=["flipid"], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "flipid", + }, + ) + await hass.async_block_till_done() + + assert len(mock_flipr_client.mock_calls) == 1 + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "flipid" + assert result["data"] == { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "flipid", + } + + +async def test_multiple_flip_id(hass, mock_setup): + """Test multiple flipr id adding a config step.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=["FLIP1", "FLIP2"], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "flipr_id" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_FLIPR_ID: "FLIP2"}, + ) + + assert len(mock_flipr_client.mock_calls) == 1 + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "FLIP2" + assert result["data"] == { + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "FLIP2", + } + + +async def test_no_flip_id(hass, mock_setup): + """Test no flipr id found.""" + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + return_value=[], + ) as mock_flipr_client: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + }, + ) + + assert result["step_id"] == "user" + assert result["type"] == "form" + assert result["errors"] == {"base": "no_flipr_id_found"} + + assert len(mock_flipr_client.mock_calls) == 1 + + +async def test_http_errors(hass, mock_setup): + """Test HTTP Errors.""" + with patch("flipr_api.FliprAPIRestClient.search_flipr_ids", side_effect=Timeout()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nada", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "flipr_api.FliprAPIRestClient.search_flipr_ids", + side_effect=Exception("Bad request Boy :) --"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={ + CONF_EMAIL: "nada", + CONF_PASSWORD: "nada", + CONF_FLIPR_ID: "", + }, + ) + + assert result["type"] == "form" + assert result["errors"] == {"base": "unknown"} diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py new file mode 100644 index 00000000000..08487c18a46 --- /dev/null +++ b/tests/components/flipr/test_init.py @@ -0,0 +1,28 @@ +"""Tests for init methods.""" +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant): + """Test unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_EMAIL: "dummylogin", + CONF_PASSWORD: "dummypass", + CONF_FLIPR_ID: "FLIP1", + }, + unique_id="123456", + ) + entry.add_to_hass(hass) + with patch("homeassistant.components.flipr.FliprAPIRestClient"): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED diff --git a/tests/components/flipr/test_sensors.py b/tests/components/flipr/test_sensors.py new file mode 100644 index 00000000000..244ec61507c --- /dev/null +++ b/tests/components/flipr/test_sensors.py @@ -0,0 +1,92 @@ +"""Test the Flipr sensor and binary sensor.""" +from datetime import datetime +from unittest.mock import patch + +from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONF_EMAIL, + CONF_PASSWORD, + TEMP_CELSIUS, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +# Data for the mocked object returned via flipr_api client. +MOCK_DATE_TIME = datetime(2021, 2, 15, 9, 10, 32, tzinfo=dt_util.UTC) +MOCK_FLIPR_MEASURE = { + "temperature": 10.5, + "ph": 7.03, + "chlorine": 0.23654886, + "red_ox": 657.58, + "date_time": MOCK_DATE_TIME, + "ph_status": "TooLow", + "chlorine_status": "Medium", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the creation and values of the Flipr sensors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test_entry_unique_id", + data={ + CONF_EMAIL: "toto@toto.com", + CONF_PASSWORD: "myPassword", + CONF_FLIPR_ID: "myfliprid", + }, + ) + + entry.add_to_hass(hass) + + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for sensors + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "my_random_entity_id", + suggested_object_id="sensor.flipr_myfliprid_chlorine", + disabled_by=None, + ) + + with patch( + "flipr_api.FliprAPIRestClient.get_pool_measure_latest", + return_value=MOCK_FLIPR_MEASURE, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.flipr_myfliprid_ph") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "7.03" + + state = hass.states.get("sensor.flipr_myfliprid_water_temp") + assert state + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is TEMP_CELSIUS + assert state.state == "10.5" + + state = hass.states.get("sensor.flipr_myfliprid_last_measured") + assert state + assert state.attributes.get(ATTR_ICON) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "2021-02-15T09:10:32+00:00" + + state = hass.states.get("sensor.flipr_myfliprid_red_ox") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.state == "657.58" + + state = hass.states.get("sensor.flipr_myfliprid_chlorine") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:pool" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.state == "0.23654886" diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py index f817b38c98b..f3bf961fdc8 100644 --- a/tests/components/foobot/test_sensor.py +++ b/tests/components/foobot/test_sensor.py @@ -51,7 +51,7 @@ async def test_default_setup(hass, aioclient_mock): } for name, value in metrics.items(): - state = hass.states.get("sensor.foobot_happybot_%s" % name) + state = hass.states.get(f"sensor.foobot_happybot_{name}") assert state.state == value[0] assert state.attributes.get("unit_of_measurement") == value[1] diff --git a/tests/components/forecast_solar/conftest.py b/tests/components/forecast_solar/conftest.py index c2b5fc08181..8b9227a8d04 100644 --- a/tests/components/forecast_solar/conftest.py +++ b/tests/components/forecast_solar/conftest.py @@ -1,9 +1,10 @@ """Fixtures for Forecast.Solar integration tests.""" -import datetime +from datetime import datetime, timedelta from typing import Generator from unittest.mock import MagicMock, patch +from forecast_solar import models import pytest from homeassistant.components.forecast_solar.const import ( @@ -16,6 +17,7 @@ from homeassistant.components.forecast_solar.const import ( from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry @@ -54,24 +56,31 @@ def mock_forecast_solar() -> Generator[None, MagicMock, None]: "homeassistant.components.forecast_solar.ForecastSolar", 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) - estimate = MagicMock() + estimate = MagicMock(spec=models.Estimate) + estimate.now.return_value = now estimate.timezone = "Europe/Amsterdam" - estimate.energy_production_today = 100 - estimate.energy_production_tomorrow = 200 - estimate.power_production_now = 300 - estimate.power_highest_peak_time_today = datetime.datetime( - 2021, 6, 27, 13, 0, tzinfo=datetime.timezone.utc + estimate.energy_production_today = 100000 + 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 ) - estimate.power_highest_peak_time_tomorrow = datetime.datetime( - 2021, 6, 27, 14, 0, tzinfo=datetime.timezone.utc + estimate.power_highest_peak_time_tomorrow = datetime( + 2021, 6, 27, 14, 0, tzinfo=dt_util.DEFAULT_TIME_ZONE ) - estimate.power_production_next_hour = 400 - estimate.power_production_next_6hours = 500 - estimate.power_production_next_12hours = 600 - estimate.power_production_next_24hours = 700 - estimate.energy_current_hour = 800 - estimate.energy_next_hour = 900 + estimate.energy_current_hour = 800000 + + estimate.power_production_at_time.side_effect = { + now + timedelta(hours=1): 400000, + now + timedelta(hours=12): 600000, + now + timedelta(hours=24): 700000, + }.get + + estimate.sum_energy_production.side_effect = { + 1: 900000, + }.get forecast_solar.estimate.return_value = estimate yield forecast_solar diff --git a/tests/components/forecast_solar/test_init.py b/tests/components/forecast_solar/test_init.py index 719041aaf58..453196e3300 100644 --- a/tests/components/forecast_solar/test_init.py +++ b/tests/components/forecast_solar/test_init.py @@ -1,4 +1,5 @@ """Tests for the Forecast.Solar integration.""" +from datetime import datetime, timezone from unittest.mock import MagicMock, patch from forecast_solar import ForecastSolarConnectionError @@ -14,14 +15,39 @@ async def test_load_unload_config_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_forecast_solar: MagicMock, + hass_ws_client, ) -> None: """Test the Forecast.Solar configuration entry loading/unloading.""" + mock_forecast_solar.estimate.return_value.wh_hours = { + datetime(2021, 6, 27, 13, 0, tzinfo=timezone.utc): 12, + datetime(2021, 6, 27, 14, 0, tzinfo=timezone.utc): 8, + } + 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 == ConfigEntryState.LOADED + # Test WS API set up + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 5, + "type": "forecast_solar/forecasts", + } + ) + result = await client.receive_json() + assert result["success"] + assert result["result"] == { + mock_config_entry.entry_id: { + "wh_hours": { + "2021-06-27T13:00:00+00:00": 12, + "2021-06-27T14:00:00+00:00": 8, + } + } + } + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index 31c367678c1..a2b105ccbd1 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -40,7 +40,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_production_today" - assert state.state == "100" + assert state.state == "100.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Today" @@ -55,7 +55,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_production_tomorrow" - assert state.state == "200" + assert state.state == "200.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Tomorrow" @@ -96,7 +96,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_production_now" - assert state.state == "300" + assert state.state == "300000" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Power Production - Now" ) @@ -110,7 +110,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_current_hour" - assert state.state == "800" + assert state.state == "800.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - This Hour" @@ -125,7 +125,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_energy_next_hour" - assert state.state == "900" + assert state.state == "900.0" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Estimated Energy Production - Next Hour" @@ -175,17 +175,17 @@ async def test_disabled_by_default( ( "power_production_next_12hours", "Estimated Power Production - Next 12 Hours", - "600", + "600000", ), ( "power_production_next_24hours", "Estimated Power Production - Next 24 Hours", - "700", + "700000", ), ( "power_production_next_hour", "Estimated Power Production - Next Hour", - "400", + "400000", ), ], ) diff --git a/tests/components/freedompro/const.py b/tests/components/freedompro/const.py index 8635858d000..2f59d190e2c 100644 --- a/tests/components/freedompro/const.py +++ b/tests/components/freedompro/const.py @@ -15,7 +15,7 @@ DEVICES = [ }, { "uid": "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*ILYH1E3DWZOVMNEUIMDYMNLOW-LFRQFDPWWJOVHVDOS", - "name": "Bedroom fan", + "name": "bedroom", "type": "fan", "characteristics": ["on", "rotationSpeed"], }, @@ -81,7 +81,7 @@ DEVICES = [ }, { "uid": "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI", - "name": "Bedroom thermostat", + "name": "thermostat", "type": "thermostat", "characteristics": [ "heatingCoolingState", @@ -91,7 +91,7 @@ DEVICES = [ }, { "uid": "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*3XSSVIJWK-65HILWTC4WINQK46SP4OEZRCNO25VGWAS", - "name": "Bedroom window covering", + "name": "blind", "type": "windowCovering", "characteristics": ["position"], }, @@ -131,7 +131,7 @@ DEVICES_STATE = [ { "uid": "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*SOT3NKALCRQMHUHJUF79NUG6UQP1IIQIN1PJVRRPT0C", "type": "contactSensor", - "state": {"contactSensorState": True}, + "state": {"contactSensorState": False}, "online": True, }, { @@ -207,7 +207,7 @@ DEVICES_STATE = [ { "uid": "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*JVRAR_6WVL1Y0PJ5GFWGPMFV7FLVD4MZKBWXC_UFWYM", "type": "lightSensor", - "state": {"currentAmbientLightLevel": 500}, + "state": {"currentAmbientLightLevel": 0}, "online": True, }, { diff --git a/tests/components/freedompro/test_binary_sensor.py b/tests/components/freedompro/test_binary_sensor.py new file mode 100644 index 00000000000..785a6b03212 --- /dev/null +++ b/tests/components/freedompro/test_binary_sensor.py @@ -0,0 +1,114 @@ +"""Tests for the Freedompro binary sensor.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + + +@pytest.mark.parametrize( + "entity_id, uid, name, model", + [ + ( + "binary_sensor.doorway_motion_sensor", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*VTEPEDYE8DXGS8U94CJKQDLKMN6CUX1IJWSOER2HZCK", + "Doorway motion sensor", + "motionSensor", + ), + ( + "binary_sensor.contact_sensor_living_room", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*SOT3NKALCRQMHUHJUF79NUG6UQP1IIQIN1PJVRRPT0C", + "Contact sensor living room", + "contactSensor", + ), + ( + "binary_sensor.living_room_occupancy_sensor", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*SNG7Y3R1R0S_W5BCNPP1O5WUN2NCEOOT27EFSYT6JYS", + "Living room occupancy sensor", + "occupancySensor", + ), + ( + "binary_sensor.smoke_sensor_kitchen", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*SXFMEXI4UMDBAMXXPI6LJV47O9NY-IRCAKZI7_MW0LY", + "Smoke sensor kitchen", + "smokeSensor", + ), + ], +) +async def test_binary_sensor_get_state( + hass, init_integration, entity_id: str, uid: str, name: str, model: str +): + """Test states of the binary_sensor.""" + init_integration + registry = er.async_get(hass) + registry_device = dr.async_get(hass) + + device = registry_device.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == name + assert device.model == model + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_OFF + + with patch( + "homeassistant.components.freedompro.get_states", + return_value=[], + ): + + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_OFF + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + if state_response["type"] == "smokeSensor": + state_response["state"]["smokeDetected"] = True + if state_response["type"] == "occupancySensor": + state_response["state"]["occupancyDetected"] = True + if state_response["type"] == "motionSensor": + state_response["state"]["motionDetected"] = True + if state_response["type"] == "contactSensor": + state_response["state"]["contactSensorState"] = True + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_ON diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py new file mode 100644 index 00000000000..36ec3309d24 --- /dev/null +++ b/tests/components/freedompro/test_climate.py @@ -0,0 +1,203 @@ +"""Tests for the Freedompro climate.""" + +from datetime import timedelta +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_HVAC_MODE, + ATTR_HVAC_MODES, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_TEMPERATURE, + DOMAIN as CLIMATE_DOMAIN, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, +) +from homeassistant.components.climate.const import HVAC_MODE_AUTO +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + +uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*TWMYQKL3UVED4HSIIB9GXJWJZBQCXG-9VE-N2IUAIWI" + + +async def test_climate_get_state(hass, init_integration): + """Test states of the climate.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == "thermostat" + assert device.model == "thermostat" + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + assert state.attributes[ATTR_HVAC_MODES] == [ + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + ] + + assert state.attributes[ATTR_MIN_TEMP] == 7 + assert state.attributes[ATTR_MAX_TEMP] == 35 + assert state.attributes[ATTR_TEMPERATURE] == 14 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 14 + + assert state.state == HVAC_MODE_HEAT + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["currentTemperature"] = 20 + state_response["state"]["targetTemperature"] = 21 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.attributes[ATTR_TEMPERATURE] == 21 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20 + + +async def test_climate_set_off(hass, init_integration): + """Test set off climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.climate.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVAC_MODE_OFF}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"heatingCoolingState": 0}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == HVAC_MODE_HEAT + + +async def test_climate_set_unsupported_hvac_mode(hass, init_integration): + """Test set unsupported hvac mode climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVAC_MODE_AUTO}, + blocking=True, + ) + + +async def test_climate_set_temperature(hass, init_integration): + """Test set temperature climate.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.climate.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_HVAC_MODE: HVAC_MODE_OFF, + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) + mock_put_state.assert_called_once_with( + ANY, ANY, ANY, '{"heatingCoolingState": 0, "targetTemperature": 25.0}' + ) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_TEMPERATURE] == 21 + + +async def test_climate_set_temperature_unsupported_hvac_mode(hass, init_integration): + """Test set temperature climate unsupported hvac mode.""" + init_integration + entity_registry = er.async_get(hass) + + entity_id = "climate.thermostat" + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "thermostat" + + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: [entity_id], + ATTR_HVAC_MODE: HVAC_MODE_AUTO, + ATTR_TEMPERATURE: 25, + }, + blocking=True, + ) diff --git a/tests/components/freedompro/test_cover.py b/tests/components/freedompro/test_cover.py new file mode 100644 index 00000000000..d0338dec82c --- /dev/null +++ b/tests/components/freedompro/test_cover.py @@ -0,0 +1,200 @@ +"""Tests for the Freedompro cover.""" +from datetime import timedelta +from unittest.mock import ANY, patch + +import pytest + +from homeassistant.components.cover import ATTR_POSITION, DOMAIN as COVER_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + STATE_CLOSED, + STATE_OPEN, +) +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + + +@pytest.mark.parametrize( + "entity_id, uid, name, model", + [ + ( + "cover.blind", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*3XSSVIJWK-65HILWTC4WINQK46SP4OEZRCNO25VGWAS", + "blind", + "windowCovering", + ) + ], +) +async def test_cover_get_state( + hass, init_integration, entity_id: str, uid: str, name: str, model: str +): + """Test states of the cover.""" + init_integration + registry = er.async_get(hass) + registry_device = dr.async_get(hass) + + device = registry_device.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == name + assert device.model == model + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["position"] = 100 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_OPEN + + +@pytest.mark.parametrize( + "entity_id, uid, name, model", + [ + ( + "cover.blind", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*3XSSVIJWK-65HILWTC4WINQK46SP4OEZRCNO25VGWAS", + "blind", + "windowCovering", + ) + ], +) +async def test_cover_set_position( + hass, init_integration, entity_id: str, uid: str, name: str, model: str +): + """Test set position of the cover.""" + init_integration + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: + assert await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: [entity_id], ATTR_POSITION: 33}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 33}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_OPEN + + +@pytest.mark.parametrize( + "entity_id, uid, name, model", + [ + ( + "cover.blind", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*3XSSVIJWK-65HILWTC4WINQK46SP4OEZRCNO25VGWAS", + "blind", + "windowCovering", + ) + ], +) +async def test_cover_close( + hass, init_integration, entity_id: str, uid: str, name: str, model: str +): + """Test close cover.""" + init_integration + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: + assert await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 0}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_OPEN + + +@pytest.mark.parametrize( + "entity_id, uid, name, model", + [ + ( + "cover.blind", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*3XSSVIJWK-65HILWTC4WINQK46SP4OEZRCNO25VGWAS", + "blind", + "windowCovering", + ) + ], +) +async def test_cover_open( + hass, init_integration, entity_id: str, uid: str, name: str, model: str +): + """Test open cover.""" + init_integration + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.cover.put_state") as mock_put_state: + assert await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"position": 100}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_OPEN diff --git a/tests/components/freedompro/test_fan.py b/tests/components/freedompro/test_fan.py new file mode 100644 index 00000000000..6bf4bbe1e04 --- /dev/null +++ b/tests/components/freedompro/test_fan.py @@ -0,0 +1,159 @@ +"""Tests for the Freedompro fan.""" +from datetime import timedelta +from unittest.mock import ANY, patch + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, STATE_OFF, STATE_ON +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + +uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*ILYH1E3DWZOVMNEUIMDYMNLOW-LFRQFDPWWJOVHVDOS" + + +async def test_fan_get_state(hass, init_integration): + """Test states of the fan.""" + init_integration + registry = er.async_get(hass) + registry_device = dr.async_get(hass) + + device = registry_device.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == "bedroom" + assert device.model == "fan" + + entity_id = "fan.bedroom" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 0 + assert state.attributes.get("friendly_name") == "bedroom" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["on"] = True + state_response["state"]["rotationSpeed"] = 50 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "bedroom" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + + +async def test_fan_set_off(hass, init_integration): + """Test turn off the fan.""" + init_integration + registry = er.async_get(hass) + + entity_id = "fan.bedroom" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes.get("friendly_name") == "bedroom" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: + assert await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": false}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.state == STATE_ON + + +async def test_fan_set_on(hass, init_integration): + """Test turn on the fan.""" + init_integration + registry = er.async_get(hass) + + entity_id = "fan.bedroom" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes.get("friendly_name") == "bedroom" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: + assert await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": true}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.state == STATE_ON + + +async def test_fan_set_percent(hass, init_integration): + """Test turn on the fan.""" + init_integration + registry = er.async_get(hass) + + entity_id = "fan.bedroom" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes.get("friendly_name") == "bedroom" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.fan.put_state") as mock_put_state: + assert await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: [entity_id], ATTR_PERCENTAGE: 40}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"rotationSpeed": 40}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.state == STATE_ON diff --git a/tests/components/freedompro/test_lock.py b/tests/components/freedompro/test_lock.py new file mode 100644 index 00000000000..5c30909e081 --- /dev/null +++ b/tests/components/freedompro/test_lock.py @@ -0,0 +1,120 @@ +"""Tests for the Freedompro lock.""" +from datetime import timedelta +from unittest.mock import ANY, patch + +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, + SERVICE_LOCK, + SERVICE_UNLOCK, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + +uid = "2WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*2VAS3HTWINNZ5N6HVEIPDJ6NX85P2-AM-GSYWUCNPU0" + + +async def test_lock_get_state(hass, init_integration): + """Test states of the lock.""" + init_integration + registry = er.async_get(hass) + registry_device = dr.async_get(hass) + + device = registry_device.async_get_device({("freedompro", uid)}) + assert device is not None + assert device.identifiers == {("freedompro", uid)} + assert device.manufacturer == "Freedompro" + assert device.name == "lock" + assert device.model == "lock" + + entity_id = "lock.lock" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNLOCKED + assert state.attributes.get("friendly_name") == "lock" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["lock"] = 1 + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "lock" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_LOCKED + + +async def test_lock_set_unlock(hass, init_integration): + """Test set on of the lock.""" + init_integration + registry = er.async_get(hass) + + entity_id = "lock.lock" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_LOCKED + assert state.attributes.get("friendly_name") == "lock" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.lock.put_state") as mock_put_state: + assert await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_UNLOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"lock": 0}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_LOCKED + + +async def test_lock_set_lock(hass, init_integration): + """Test set on of the lock.""" + init_integration + registry = er.async_get(hass) + + entity_id = "lock.lock" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_LOCKED + assert state.attributes.get("friendly_name") == "lock" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch("homeassistant.components.freedompro.lock.put_state") as mock_put_state: + assert await hass.services.async_call( + LOCK_DOMAIN, + SERVICE_LOCK, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"lock": 1}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_LOCKED diff --git a/tests/components/freedompro/test_sensor.py b/tests/components/freedompro/test_sensor.py new file mode 100644 index 00000000000..b6f809569f1 --- /dev/null +++ b/tests/components/freedompro/test_sensor.py @@ -0,0 +1,76 @@ +"""Tests for the Freedompro sensor.""" +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + + +@pytest.mark.parametrize( + "entity_id, uid, name", + [ + ( + "sensor.garden_humidity_sensor", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*QN-DDFMPEPRDOQV7W7JQG3NL0NPZGTLIBYT3HFSPNEY", + "Garden humidity sensor", + ), + ( + "sensor.living_room_temperature_sensor", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*LWPVY7X1AX0DRWLYUUNZ3ZSTHMYNDDBQTPZCZQUUASA", + "Living room temperature sensor", + ), + ( + "sensor.garden_light_sensors", + "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*JVRAR_6WVL1Y0PJ5GFWGPMFV7FLVD4MZKBWXC_UFWYM", + "Garden light sensors", + ), + ], +) +async def test_sensor_get_state( + hass, init_integration, entity_id: str, uid: str, name: str +): + """Test states of the sensor.""" + init_integration + registry = er.async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == "0" + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + if state_response["type"] == "lightSensor": + state_response["state"]["currentAmbientLightLevel"] = "1" + if state_response["type"] == "temperatureSensor": + state_response["state"]["currentTemperature"] = "1" + if state_response["type"] == "humiditySensor": + state_response["state"]["currentRelativeHumidity"] = "1" + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == name + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == "1" diff --git a/tests/components/freedompro/test_switch.py b/tests/components/freedompro/test_switch.py new file mode 100644 index 00000000000..4674b684c41 --- /dev/null +++ b/tests/components/freedompro/test_switch.py @@ -0,0 +1,112 @@ +"""Tests for the Freedompro switch.""" +from datetime import timedelta +from unittest.mock import ANY, patch + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, STATE_OFF, STATE_ON +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed +from tests.components.freedompro.const import DEVICES_STATE + +uid = "3WRRJR6RCZQZSND8VP0YTO3YXCSOFPKBMW8T51TU-LQ*1JKU1MVWHQL-Z9SCUS85VFXMRGNDCDNDDUVVDKBU31W" + + +async def test_switch_get_state(hass, init_integration): + """Test states of the switch.""" + init_integration + registry = er.async_get(hass) + + entity_id = "switch.irrigation_switch" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes.get("friendly_name") == "Irrigation switch" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + get_states_response = list(DEVICES_STATE) + for state_response in get_states_response: + if state_response["uid"] == uid: + state_response["state"]["on"] = True + with patch( + "homeassistant.components.freedompro.get_states", + return_value=get_states_response, + ): + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.attributes.get("friendly_name") == "Irrigation switch" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + assert state.state == STATE_ON + + +async def test_switch_set_off(hass, init_integration): + """Test set off of the switch.""" + init_integration + registry = er.async_get(hass) + + entity_id = "switch.irrigation_switch" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes.get("friendly_name") == "Irrigation switch" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.switch.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": false}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_switch_set_on(hass, init_integration): + """Test set on of the switch.""" + init_integration + registry = er.async_get(hass) + + entity_id = "switch.irrigation_switch" + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON + assert state.attributes.get("friendly_name") == "Irrigation switch" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == uid + + with patch( + "homeassistant.components.freedompro.switch.put_state" + ) as mock_put_state: + assert await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [entity_id]}, + blocking=True, + ) + mock_put_state.assert_called_once_with(ANY, ANY, ANY, '{"on": true}') + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_ON diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index ee5d15bd1b8..da6bd982d9d 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -5,22 +5,16 @@ from typing import Any from unittest.mock import Mock from homeassistant.components.fritzbox.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from .const import ( + CONF_FAKE_AIN, + CONF_FAKE_MANUFACTURER, + CONF_FAKE_NAME, + CONF_FAKE_PRODUCTNAME, +) -MOCK_CONFIG = { - DOMAIN: { - CONF_DEVICES: [ - { - CONF_HOST: "fake_host", - CONF_PASSWORD: "fake_pass", - CONF_USERNAME: "fake_user", - } - ] - } -} +from tests.common import MockConfigEntry async def setup_config_entry( @@ -45,27 +39,33 @@ async def setup_config_entry( return result -class FritzDeviceBinarySensorMock(Mock): +class FritzDeviceBaseMock(Mock): + """base mock of a AVM Fritz!Box binary sensor device.""" + + ain = CONF_FAKE_AIN + manufacturer = CONF_FAKE_MANUFACTURER + name = CONF_FAKE_NAME + productname = CONF_FAKE_PRODUCTNAME + + +class FritzDeviceBinarySensorMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box binary sensor device.""" - ain = "fake_ain" alert_state = "fake_state" + battery_level = 23 fw_version = "1.2.3" has_alarm = True + has_powermeter = False has_switch = False has_temperature_sensor = False has_thermostat = False - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" -class FritzDeviceClimateMock(Mock): +class FritzDeviceClimateMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box climate device.""" actual_temperature = 18.0 - ain = "fake_ain" alert_state = "fake_state" battery_level = 23 battery_low = True @@ -74,55 +74,48 @@ class FritzDeviceClimateMock(Mock): eco_temperature = 16.0 fw_version = "1.2.3" has_alarm = False + has_powermeter = False has_switch = False has_temperature_sensor = False has_thermostat = True holiday_active = "fake_holiday" lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" summer_active = "fake_summer" target_temperature = 19.5 window_open = "fake_window" -class FritzDeviceSensorMock(Mock): +class FritzDeviceSensorMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box sensor device.""" - ain = "fake_ain" battery_level = 23 device_lock = "fake_locked_device" fw_version = "1.2.3" has_alarm = False + has_powermeter = False has_switch = False has_temperature_sensor = True has_thermostat = False lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" present = True - productname = "fake_productname" temperature = 1.23 -class FritzDeviceSwitchMock(Mock): +class FritzDeviceSwitchMock(FritzDeviceBaseMock): """Mock of a AVM Fritz!Box switch device.""" - ain = "fake_ain" + battery_level = None device_lock = "fake_locked_device" energy = 1234 fw_version = "1.2.3" has_alarm = False + has_powermeter = True has_switch = True has_temperature_sensor = True has_thermostat = False switch_state = "fake_state" lock = "fake_locked" - manufacturer = "fake_manufacturer" - name = "fake_name" power = 5678 present = True - productname = "fake_productname" - temperature = 135 + temperature = 1.23 diff --git a/tests/components/fritzbox/const.py b/tests/components/fritzbox/const.py new file mode 100644 index 00000000000..1b8bc927800 --- /dev/null +++ b/tests/components/fritzbox/const.py @@ -0,0 +1,20 @@ +"""Constants for fritzbox tests.""" +from homeassistant.components.fritzbox.const import DOMAIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +MOCK_CONFIG = { + DOMAIN: { + CONF_DEVICES: [ + { + CONF_HOST: "fake_host", + CONF_PASSWORD: "fake_pass", + CONF_USERNAME: "fake_user", + } + ] + } +} + +CONF_FAKE_NAME = "fake_name" +CONF_FAKE_AIN = "fake_ain" +CONF_FAKE_MANUFACTURER = "fake_manufacturer" +CONF_FAKE_PRODUCTNAME = "fake_productname" diff --git a/tests/components/fritzbox/test_binary_sensor.py b/tests/components/fritzbox/test_binary_sensor.py index 7a2d2347004..cb76109e0ff 100644 --- a/tests/components/fritzbox/test_binary_sensor.py +++ b/tests/components/fritzbox/test_binary_sensor.py @@ -7,21 +7,25 @@ from requests.exceptions import HTTPError from homeassistant.components.binary_sensor import DOMAIN from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, - STATE_OFF, + PERCENTAGE, STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceBinarySensorMock, setup_config_entry +from . import FritzDeviceBinarySensorMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -34,8 +38,16 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_DEVICE_CLASS] == "window" + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") + assert state + assert state.state == "23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes async def test_is_off(hass: HomeAssistant, fritz: Mock): @@ -48,7 +60,7 @@ async def test_is_off(hass: HomeAssistant, fritz: Mock): state = hass.states.get(ENTITY_ID) assert state - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE async def test_update(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py index 59d32e18c34..30ee7130fea 100644 --- a/tests/components/fritzbox/test_climate.py +++ b/tests/components/fritzbox/test_climate.py @@ -30,21 +30,25 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_WINDOW_OPEN, DOMAIN as FB_DOMAIN, ) +from homeassistant.components.sensor import ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, + PERCENTAGE, ) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceClimateMock, setup_config_entry +from . import FritzDeviceClimateMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -58,7 +62,7 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state assert state.attributes[ATTR_BATTERY_LEVEL] == 23 assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 18 - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_HVAC_MODES] == [HVAC_MODE_HEAT, HVAC_MODE_OFF] assert state.attributes[ATTR_MAX_TEMP] == 28 assert state.attributes[ATTR_MIN_TEMP] == 8 @@ -71,8 +75,16 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_STATE_SUMMER_MODE] == "fake_summer" assert state.attributes[ATTR_STATE_WINDOW_OPEN] == "fake_window" assert state.attributes[ATTR_TEMPERATURE] == 19.5 + assert ATTR_STATE_CLASS not in state.attributes assert state.state == HVAC_MODE_HEAT + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_battery") + assert state + assert state.state == "23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes + async def test_target_temperature_on(hass: HomeAssistant, fritz: Mock): """Test turn device on.""" diff --git a/tests/components/fritzbox/test_config_flow.py b/tests/components/fritzbox/test_config_flow.py index a9de92060ec..6d62122a871 100644 --- a/tests/components/fritzbox/test_config_flow.py +++ b/tests/components/fritzbox/test_config_flow.py @@ -21,14 +21,14 @@ from homeassistant.data_entry_flow import ( RESULT_TYPE_FORM, ) -from . import MOCK_CONFIG +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] MOCK_SSDP_DATA = { ATTR_SSDP_LOCATION: "https://fake_host:12345/test", - ATTR_UPNP_FRIENDLY_NAME: "fake_name", + ATTR_UPNP_FRIENDLY_NAME: CONF_FAKE_NAME, ATTR_UPNP_UDN: "uuid:only-a-test", } @@ -192,7 +192,7 @@ async def test_ssdp(hass: HomeAssistant, fritz: Mock): user_input={CONF_PASSWORD: "fake_pass", CONF_USERNAME: "fake_user"}, ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "fake_name" + assert result["title"] == CONF_FAKE_NAME assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_PASSWORD] == "fake_pass" assert result["data"][CONF_USERNAME] == "fake_user" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 438335868cd..ea0356c6af1 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -7,6 +7,7 @@ from pyfritzhome import LoginError from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import DOMAIN as FB_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( @@ -15,10 +16,13 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from . import MOCK_CONFIG, FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, setup_config_entry +from .const import CONF_FAKE_AIN, CONF_FAKE_NAME, MOCK_CONFIG from tests.common import MockConfigEntry @@ -38,6 +42,58 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): ] +async def test_update_unique_id(hass: HomeAssistant, fritz: Mock): + """Test unique_id update of integration.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + FB_DOMAIN, + CONF_FAKE_AIN, + unit_of_measurement=TEMP_CELSIUS, + config_entry=entry, + ) + assert entity.unique_id == CONF_FAKE_AIN + assert await hass.config_entries.async_setup(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 == f"{CONF_FAKE_AIN}_temperature" + + +async def test_update_unique_id_no_change(hass: HomeAssistant, fritz: Mock): + """Test unique_id is not updated of integration.""" + entry = MockConfigEntry( + domain=FB_DOMAIN, + data=MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + FB_DOMAIN, + f"{CONF_FAKE_AIN}_temperature", + unit_of_measurement=TEMP_CELSIUS, + config_entry=entry, + ) + assert entity.unique_id == f"{CONF_FAKE_AIN}_temperature" + assert await hass.config_entries.async_setup(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 == f"{CONF_FAKE_AIN}_temperature" + + async def test_coordinator_update_after_reboot(hass: HomeAssistant, fritz: Mock): """Test coordinator after reboot.""" entry = MockConfigEntry( @@ -74,7 +130,7 @@ async def test_coordinator_update_after_password_change( async def test_unload_remove(hass: HomeAssistant, fritz: Mock): """Test unload and remove of integration.""" fritz().get_devices.return_value = [FritzDeviceSwitchMock()] - entity_id = f"{SWITCH_DOMAIN}.fake_name" + entity_id = f"{SWITCH_DOMAIN}.{CONF_FAKE_NAME}" entry = MockConfigEntry( domain=FB_DOMAIN, diff --git a/tests/components/fritzbox/test_sensor.py b/tests/components/fritzbox/test_sensor.py index c1d82a93189..664b6765c03 100644 --- a/tests/components/fritzbox/test_sensor.py +++ b/tests/components/fritzbox/test_sensor.py @@ -9,7 +9,11 @@ from homeassistant.components.fritzbox.const import ( ATTR_STATE_LOCKED, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.sensor import DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN, + STATE_CLASS_MEASUREMENT, +) from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, @@ -20,11 +24,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceSensorMock, setup_config_entry +from . import FritzDeviceSensorMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -33,20 +38,23 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert await setup_config_entry( hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz ) + await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + state = hass.states.get(f"{ENTITY_ID}_temperature") assert state assert state.state == "1.23" - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT state = hass.states.get(f"{ENTITY_ID}_battery") assert state assert state.state == "23" - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name Battery" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Battery" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_STATE_CLASS not in state.attributes async def test_update(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index cc0caeafa69..951528f1e7d 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -7,18 +7,22 @@ from requests.exceptions import HTTPError from homeassistant.components.fritzbox.const import ( ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, - ATTR_TEMPERATURE_UNIT, - ATTR_TOTAL_CONSUMPTION, - ATTR_TOTAL_CONSUMPTION_UNIT, DOMAIN as FB_DOMAIN, ) -from homeassistant.components.switch import ATTR_CURRENT_POWER_W, DOMAIN +from homeassistant.components.sensor import ( + ATTR_LAST_RESET, + ATTR_STATE_CLASS, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASS_MEASUREMENT, +) +from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, ENERGY_KILO_WATT_HOUR, + POWER_WATT, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, @@ -27,11 +31,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util -from . import MOCK_CONFIG, FritzDeviceSwitchMock, setup_config_entry +from . import FritzDeviceSwitchMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG from tests.common import async_fire_time_changed -ENTITY_ID = f"{DOMAIN}.fake_name" +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" async def test_setup(hass: HomeAssistant, fritz: Mock): @@ -44,14 +49,34 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): state = hass.states.get(ENTITY_ID) assert state assert state.state == STATE_ON - assert state.attributes[ATTR_CURRENT_POWER_W] == 5.678 - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" - assert state.attributes[ATTR_TEMPERATURE] == "135" - assert state.attributes[ATTR_TEMPERATURE_UNIT] == TEMP_CELSIUS - assert state.attributes[ATTR_TOTAL_CONSUMPTION] == "1.234" - assert state.attributes[ATTR_TOTAL_CONSUMPTION_UNIT] == ENERGY_KILO_WATT_HOUR + assert ATTR_STATE_CLASS not in state.attributes + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature") + assert state + assert state.state == "1.23" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" + assert state.attributes[ATTR_STATE_DEVICE_LOCKED] == "fake_locked_device" + assert state.attributes[ATTR_STATE_LOCKED] == "fake_locked" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power_consumption") + assert state + assert state.state == "5.678" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Power Consumption" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT + + state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy") + assert state + assert state.state == "1.234" + assert state.attributes[ATTR_LAST_RESET] == "1970-01-01T00:00:00+00:00" + assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT async def test_turn_on(hass: HomeAssistant, fritz: Mock): diff --git a/tests/components/garmin_connect/__init__.py b/tests/components/garmin_connect/__init__.py deleted file mode 100644 index 26de06ae0ac..00000000000 --- a/tests/components/garmin_connect/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Garmin Connect component.""" diff --git a/tests/components/garmin_connect/test_config_flow.py b/tests/components/garmin_connect/test_config_flow.py deleted file mode 100644 index dd56fba9c1c..00000000000 --- a/tests/components/garmin_connect/test_config_flow.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test the Garmin Connect config flow.""" -from unittest.mock import patch - -from garminconnect_ha import ( - GarminConnectAuthenticationError, - GarminConnectConnectionError, - GarminConnectTooManyRequestsError, -) - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.garmin_connect.const import DOMAIN -from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME - -from tests.common import MockConfigEntry - -MOCK_CONF = { - CONF_ID: "my@email.address", - CONF_USERNAME: "my@email.address", - CONF_PASSWORD: "mypassw0rd", -} - - -async def test_show_form(hass): - """Test that the form is served with no input.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == config_entries.SOURCE_USER - - -async def test_step_user(hass): - """Test registering an integration and finishing flow works.""" - with patch( - "homeassistant.components.garmin_connect.async_setup_entry", return_value=True - ), patch( - "homeassistant.components.garmin_connect.config_flow.Garmin", - ) as garmin: - garmin.return_value.login.return_value = MOCK_CONF[CONF_ID] - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == MOCK_CONF - - -async def test_connection_error(hass): - """Test for connection error.""" - with patch( - "homeassistant.components.garmin_connect.Garmin.login", - side_effect=GarminConnectConnectionError("errormsg"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "cannot_connect"} - - -async def test_authentication_error(hass): - """Test for authentication error.""" - with patch( - "homeassistant.components.garmin_connect.Garmin.login", - side_effect=GarminConnectAuthenticationError("errormsg"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "invalid_auth"} - - -async def test_toomanyrequest_error(hass): - """Test for toomanyrequests error.""" - with patch( - "homeassistant.components.garmin_connect.Garmin.login", - side_effect=GarminConnectTooManyRequestsError("errormsg"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "too_many_requests"} - - -async def test_unknown_error(hass): - """Test for unknown error.""" - with patch( - "homeassistant.components.garmin_connect.Garmin.login", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} - - -async def test_abort_if_already_setup(hass): - """Test abort if already setup.""" - with patch( - "homeassistant.components.garmin_connect.config_flow.Garmin", - ): - entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONF, unique_id=MOCK_CONF[CONF_ID] - ) - entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "user"}, data=MOCK_CONF - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" diff --git a/tests/components/generic_hygrostat/__init__.py b/tests/components/generic_hygrostat/__init__.py new file mode 100644 index 00000000000..6c3a131276c --- /dev/null +++ b/tests/components/generic_hygrostat/__init__.py @@ -0,0 +1 @@ +"""Tests for the generic_hygrostat component.""" diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py new file mode 100644 index 00000000000..d0412731c78 --- /dev/null +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -0,0 +1,1657 @@ +"""The tests for the generic_hygrostat.""" +import datetime +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components import input_boolean, switch +from homeassistant.components.humidifier.const import ( + ATTR_HUMIDITY, + DOMAIN, + MODE_AWAY, + MODE_NORMAL, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_MODE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +import homeassistant.core as ha +from homeassistant.core import DOMAIN as HASS_DOMAIN, CoreState, State, callback +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import ( + assert_setup_component, + async_fire_time_changed, + mock_restore_cache, +) + +ENTITY = "humidifier.test" +ENT_SENSOR = "sensor.test" +ENT_SWITCH = "switch.test" +ATTR_SAVED_HUMIDITY = "saved_humidity" +MIN_HUMIDITY = 20 +MAX_HUMIDITY = 65 +TARGET_HUMIDITY = 42 + + +async def test_setup_missing_conf(hass): + """Test set up humidity_control with missing config values.""" + config = { + "platform": "generic_hygrostat", + "name": "test", + "target_sensor": ENT_SENSOR, + } + with assert_setup_component(0): + await async_setup_component(hass, "humidifier", {"humidifier": config}) + await hass.async_block_till_done() + + +async def test_valid_conf(hass): + """Test set up generic_hygrostat with valid config values.""" + assert await async_setup_component( + hass, + "humidifier", + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_comp_1(hass): + """Initialize components.""" + assert await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + +async def test_humidifier_input_boolean(hass, setup_comp_1): + """Test humidifier switching input_boolean.""" + humidifier_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_OFF + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + +async def test_humidifier_switch(hass, setup_comp_1, enable_custom_integrations): + """Test humidifier switching test switch.""" + platform = getattr(hass.components, "test.switch") + platform.init() + switch_1 = platform.ENTITIES[1] + assert await async_setup_component( + hass, switch.DOMAIN, {"switch": {"platform": "test"}} + ) + await hass.async_block_till_done() + humidifier_switch = switch_1.entity_id + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + } + }, + ) + + await hass.async_block_till_done() + assert hass.states.get(humidifier_switch).state == STATE_OFF + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + +def _setup_sensor(hass, humidity): + """Set up the test sensor.""" + hass.states.async_set(ENT_SENSOR, humidity) + + +@pytest.fixture +async def setup_comp_0(hass): + """Initialize components.""" + _setup_sensor(hass, 45) + hass.states.async_set(ENT_SWITCH, STATE_OFF) + await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "away_humidity": 35, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_comp_2(hass): + """Initialize components.""" + _setup_sensor(hass, 45) + hass.states.async_set(ENT_SWITCH, STATE_OFF) + await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + "initial_state": True, + } + }, + ) + await hass.async_block_till_done() + + +async def test_unavailable_state(hass): + """Test the setting of defaults to unknown.""" + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + } + }, + ) + # The target sensor is unavailable, that should propagate to the humidifier entity: + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_UNAVAILABLE + + # Sensor online + _setup_sensor(hass, 30) + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_OFF + + +async def test_setup_defaults_to_unknown(hass): + """Test the setting of defaults to unknown.""" + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 35, + } + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ENTITY).state == STATE_UNAVAILABLE + + +async def test_default_setup_params(hass, setup_comp_2): + """Test the setup with default parameters.""" + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == 0 + assert state.attributes.get("max_humidity") == 100 + assert state.attributes.get("humidity") == 0 + + +async def test_default_setup_params_dehumidifier(hass, setup_comp_0): + """Test the setup with default parameters for dehumidifier.""" + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == 0 + assert state.attributes.get("max_humidity") == 100 + assert state.attributes.get("humidity") == 100 + + +async def test_get_modes(hass, setup_comp_2): + """Test that the attributes returns the correct modes.""" + state = hass.states.get(ENTITY) + modes = state.attributes.get("available_modes") + assert modes == [MODE_NORMAL, MODE_AWAY] + + +async def test_set_target_humidity(hass, setup_comp_2): + """Test the setting of the target humidity.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 40}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 40 + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: None}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 40 + + +async def test_set_away_mode(hass, setup_comp_2): + """Test the setting away mode.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + + +async def test_set_away_mode_and_restore_prev_humidity(hass, setup_comp_2): + """Test the setting and removing away mode. + + Verify original humidity is restored. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 44 + + +async def test_set_away_mode_twice_and_restore_prev_humidity(hass, setup_comp_2): + """Test the setting away mode twice in a row. + + Verify original humidity is restored. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 35 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 44 + + +async def test_sensor_bad_value(hass, setup_comp_2): + """Test sensor that have None as state.""" + state = hass.states.get(ENTITY) + humidity = state.attributes.get("current_humidity") + + _setup_sensor(hass, None) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY) + assert humidity == state.attributes.get("current_humidity") + + +async def test_set_target_humidity_humidifier_on(hass, setup_comp_2): + """Test if target humidity turn humidifier on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 36) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, + blocking=True, + ) + 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_set_target_humidity_humidifier_off(hass, setup_comp_2): + """Test if target humidity turn humidifier off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 36}, + blocking=True, + ) + 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_humidity_change_humidifier_on_within_tolerance(hass, setup_comp_2): + """Test if humidity change doesn't turn on within tolerance.""" + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 43) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_on_outside_tolerance(hass, setup_comp_2): + """Test if humidity change turn humidifier on outside dry tolerance.""" + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 44}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 42) + 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_humidity_change_humidifier_off_within_tolerance(hass, setup_comp_2): + """Test if humidity change doesn't turn off within tolerance.""" + calls = await _setup_switch(hass, True) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 48) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_off_outside_tolerance(hass, setup_comp_2): + """Test if humidity change turn humidifier off outside wet tolerance.""" + calls = await _setup_switch(hass, True) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 46}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 50) + 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_operation_mode_humidify(hass, setup_comp_2): + """Test change mode from OFF to HUMIDIFY. + + Switch turns on when humidity below setpoint and mode changes. + """ + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 45}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 40) + await hass.async_block_till_done() + calls = await _setup_switch(hass, False) + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + 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 _setup_switch(hass, is_on): + """Set up the test switch.""" + hass.states.async_set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF) + calls = [] + + @callback + def log_call(call): + """Log service calls.""" + calls.append(call) + + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, log_call) + hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, log_call) + + await hass.async_block_till_done() + return calls + + +@pytest.fixture +async def setup_comp_3(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "away_humidity": 30, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_set_target_humidity_dry_off(hass, setup_comp_3): + """Test if target humidity turn dry off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 50) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 55}, + blocking=True, + ) + 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_turn_away_mode_on_drying(hass, setup_comp_3): + """Test the setting away mode when drying.""" + await _setup_switch(hass, True) + _setup_sensor(hass, 50) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 34}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: ENTITY, ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.attributes.get("humidity") == 30 + + +async def test_operation_mode_dry(hass, setup_comp_3): + """Test change mode from OFF to DRY. + + Switch turns on when humidity below setpoint and state changes. + """ + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 30) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + 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_set_target_humidity_dry_on(hass, setup_comp_3): + """Test if target humidity turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + 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_init_ignores_tolerance(hass, setup_comp_3): + """Test if tolerance is ignored on initialization.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 39) + await hass.async_block_till_done() + assert 1 == len(calls) + call = calls[0] + assert HASS_DOMAIN == call.domain + assert SERVICE_TURN_OFF == call.service + assert ENT_SWITCH == call.data["entity_id"] + + +async def test_humidity_change_dry_off_within_tolerance(hass, setup_comp_3): + """Test if humidity change doesn't turn dry off within tolerance.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + _setup_sensor(hass, 39) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_set_humidity_change_dry_off_outside_tolerance(hass, setup_comp_3): + """Test if humidity change turn dry off.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 36) + 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_humidity_change_dry_on_within_tolerance(hass, setup_comp_3): + """Test if humidity change doesn't turn dry on within tolerance.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 37) + _setup_sensor(hass, 41) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_on_outside_tolerance(hass, setup_comp_3): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + 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_running_when_operating_mode_is_off_2(hass, setup_comp_3): + """Test that the switch turns off when enabled is set False.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + 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_no_state_change_when_operation_mode_off_2(hass, setup_comp_3): + """Test that the switch doesn't turn on when enabled is False.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 30) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +@pytest.fixture +async def setup_comp_4(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "min_cycle_duration": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_dry_trigger_on_not_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_trigger_on_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + 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_humidity_change_dry_trigger_off_not_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_dry_trigger_off_long_enough(hass, setup_comp_4): + """Test if humidity change turn dry on.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + 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_dry_trigger_off_not_long_enough(hass, setup_comp_4): + """Test if mode change turns dry off despite minimum cycle.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + 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_dry_trigger_on_not_long_enough(hass, setup_comp_4): + """Test if mode change turns dry on despite minimum cycle.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + 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_6(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_cycle_duration": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_humidifier_trigger_off_not_long_enough( + hass, setup_comp_6 +): + """Test if humidity change doesn't turn humidifier off because of time.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_trigger_on_not_long_enough( + hass, setup_comp_6 +): + """Test if humidity change doesn't turn humidifier on because of time.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_humidity_change_humidifier_trigger_on_long_enough(hass, setup_comp_6): + """Test if humidity change turn humidifier on after min cycle.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + 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_humidity_change_humidifier_trigger_off_long_enough(hass, setup_comp_6): + """Test if humidity change turn humidifier off after min cycle.""" + fake_changed = datetime.datetime( + 1918, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc + ) + with patch( + "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed + ): + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 45) + 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_humidifier_trigger_off_not_long_enough(hass, setup_comp_6): + """Test if mode change turns humidifier off despite minimum cycle.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + 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_humidifier_trigger_on_not_long_enough(hass, setup_comp_6): + """Test if mode change turns humidifier on despite minimum cycle.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert len(calls) == 0 + + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + 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.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "min_cycle_duration": datetime.timedelta(minutes=15), + "keep_alive": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_dry_trigger_on_long_enough_3(hass, setup_comp_7): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + 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_humidity_change_dry_trigger_off_long_enough_3(hass, setup_comp_7): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + 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 + + +@pytest.fixture +async def setup_comp_8(hass): + """Initialize components.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 3, + "wet_tolerance": 3, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_cycle_duration": datetime.timedelta(minutes=15), + "keep_alive": datetime.timedelta(minutes=10), + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + + +async def test_humidity_change_humidifier_trigger_on_long_enough_2(hass, setup_comp_8): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 35) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + 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_humidity_change_humidifier_trigger_off_long_enough_2(hass, setup_comp_8): + """Test if turn on signal is sent at keep-alive intervals.""" + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 45) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=5)) + await hass.async_block_till_done() + assert len(calls) == 0 + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=10)) + 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_float_tolerance_values(hass): + """Test if dehumidifier does not turn on within floating point tolerance.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 0.2, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 45) + _setup_sensor(hass, 39.9) + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_float_tolerance_values_2(hass): + """Test if dehumidifier turns off when oudside of floating point tolerance values.""" + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 0.2, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + "initial_state": True, + "target_humidity": 40, + } + }, + ) + await hass.async_block_till_done() + calls = await _setup_switch(hass, True) + _setup_sensor(hass, 39.7) + 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_custom_setup_params(hass): + """Test the setup with custom parameters.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + result = await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "min_humidity": MIN_HUMIDITY, + "max_humidity": MAX_HUMIDITY, + "target_humidity": TARGET_HUMIDITY, + } + }, + ) + await hass.async_block_till_done() + assert result + state = hass.states.get(ENTITY) + assert state.attributes.get("min_humidity") == MIN_HUMIDITY + assert state.attributes.get("max_humidity") == MAX_HUMIDITY + assert state.attributes.get("humidity") == TARGET_HUMIDITY + + +async def test_restore_state(hass): + """Ensure states are restored on startup.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40", ATTR_MODE: MODE_AWAY}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + +async def test_restore_state_target_humidity(hass): + """Ensure restore target humidity if available.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40"}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + "target_humidity": 50, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + + +async def test_restore_state_and_return_to_normal(hass): + """Ensure retain of target humidity for normal mode.""" + _setup_sensor(hass, 55) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + { + ATTR_ENTITY_ID: ENTITY, + ATTR_HUMIDITY: "40", + ATTR_MODE: MODE_AWAY, + ATTR_SAVED_HUMIDITY: "50", + }, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 50 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 50 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + +async def test_no_restore_state(hass): + """Ensure states are restored on startup if they exist. + + Allows for graceful reboot. + """ + _setup_sensor(hass, 45) + await hass.async_block_till_done() + mock_restore_cache( + hass, + ( + State( + "humidifier.test_hygrostat", + STATE_OFF, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: "40", ATTR_MODE: MODE_AWAY}, + ), + ), + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "target_humidity": 42, + "away_humidity": 35, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + + +async def test_restore_state_uncoherence_case(hass): + """ + Test restore from a strange state. + + - Turn the generic hygrostat off + - Restart HA and restore state from DB + """ + _mock_restore_cache(hass, humidity=40) + + calls = await _setup_switch(hass, False) + _setup_sensor(hass, 35) + await _setup_humidifier(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY) + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.state == STATE_OFF + assert len(calls) == 0 + + calls = await _setup_switch(hass, False) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert state.state == STATE_OFF + + +async def _setup_humidifier(hass): + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "dry_tolerance": 2, + "wet_tolerance": 4, + "away_humidity": 32, + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "device_class": "dehumidifier", + } + }, + ) + await hass.async_block_till_done() + + +def _mock_restore_cache(hass, humidity=40, state=STATE_OFF): + mock_restore_cache( + hass, + ( + State( + ENTITY, + state, + { + ATTR_ENTITY_ID: ENTITY, + ATTR_HUMIDITY: str(humidity), + ATTR_MODE: MODE_AWAY, + }, + ), + ), + ) + + +async def test_away_fixed_humidity_mode(hass): + """Ensure retain of target humidity for normal mode.""" + _setup_sensor(hass, 45) + await hass.async_block_till_done() + await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test_hygrostat", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "away_humidity": 32, + "target_humidity": 40, + "away_fixed": True, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 40 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + # Switch to Away mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + + # Target humidity changed to away_humidity + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.attributes[ATTR_HUMIDITY] == 32 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 40 + assert state.state == STATE_OFF + + # Change target humidity + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_HUMIDITY: 42}, + blocking=True, + ) + await hass.async_block_till_done() + + # Current target humidity not changed + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 32 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 42 + assert state.attributes[ATTR_MODE] == MODE_AWAY + assert state.state == STATE_OFF + + # Return to Normal mode + await hass.services.async_call( + DOMAIN, + SERVICE_SET_MODE, + {ATTR_ENTITY_ID: "humidifier.test_hygrostat", ATTR_MODE: MODE_NORMAL}, + blocking=True, + ) + await hass.async_block_till_done() + + # Target humidity changed to away_humidity + state = hass.states.get("humidifier.test_hygrostat") + assert state.attributes[ATTR_HUMIDITY] == 42 + assert state.attributes[ATTR_SAVED_HUMIDITY] == 32 + assert state.attributes[ATTR_MODE] == MODE_NORMAL + assert state.state == STATE_OFF + + +async def test_sensor_stale_duration(hass, setup_comp_1, caplog): + """Test turn off on sensor stale.""" + + humidifier_switch = "input_boolean.test" + assert await async_setup_component( + hass, input_boolean.DOMAIN, {"input_boolean": {"test": None}} + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": humidifier_switch, + "target_sensor": ENT_SENSOR, + "initial_state": True, + "sensor_stale_duration": {"minutes": 10}, + } + }, + ) + await hass.async_block_till_done() + + _setup_sensor(hass, 23) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HUMIDITY, + {ATTR_ENTITY_ID: ENTITY, ATTR_HUMIDITY: 32}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(humidifier_switch).state == STATE_ON + + # Wait 11 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=11)) + await hass.async_block_till_done() + + # 11 minutes later, no news from the sensor : emergency cut off + assert hass.states.get(humidifier_switch).state == STATE_OFF + assert "emergency" in caplog.text + + # Updated value from sensor received + _setup_sensor(hass, 24) + await hass.async_block_till_done() + + # A new value has arrived, the humidifier should go ON + assert hass.states.get(humidifier_switch).state == STATE_ON + + # Manual turn off + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + assert hass.states.get(humidifier_switch).state == STATE_OFF + + # Wait another 11 minutes + async_fire_time_changed(hass, dt_util.utcnow() + datetime.timedelta(minutes=22)) + await hass.async_block_till_done() + + # Still off + assert hass.states.get(humidifier_switch).state == STATE_OFF + + # Updated value from sensor received + _setup_sensor(hass, 22) + await hass.async_block_till_done() + + # Not turning on by itself + assert hass.states.get(humidifier_switch).state == STATE_OFF diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 537d6265125..6c39ee35303 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -12,7 +12,9 @@ STATIONS = [ ] -async def init_integration(hass, incomplete_data=False) -> MockConfigEntry: +async def init_integration( + hass, incomplete_data=False, invalid_indexes=False +) -> MockConfigEntry: """Set up the GIOS integration in Home Assistant.""" entry = MockConfigEntry( domain=DOMAIN, @@ -28,6 +30,8 @@ async def init_integration(hass, incomplete_data=False) -> MockConfigEntry: indexes["stIndexLevel"]["indexLevelName"] = "foo" sensors["pm10"]["values"][0]["value"] = None sensors["pm10"]["values"][1]["value"] = None + if invalid_indexes: + indexes = {} with patch( "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS diff --git a/tests/components/gios/test_air_quality.py b/tests/components/gios/test_air_quality.py deleted file mode 100644 index b7ce8d1f97a..00000000000 --- a/tests/components/gios/test_air_quality.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Test air_quality of GIOS integration.""" -from datetime import timedelta -import json -from unittest.mock import patch - -from gios import ApiError - -from homeassistant.components.air_quality import ( - ATTR_AQI, - ATTR_CO, - ATTR_NO2, - ATTR_OZONE, - ATTR_PM_2_5, - ATTR_PM_10, - ATTR_SO2, - DOMAIN as AIR_QUALITY_DOMAIN, -) -from homeassistant.components.gios.air_quality import ATTRIBUTION -from homeassistant.components.gios.const import AQI_GOOD, DOMAIN -from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - STATE_UNAVAILABLE, -) -from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow - -from tests.common import async_fire_time_changed, load_fixture -from tests.components.gios import init_integration - - -async def test_air_quality(hass): - """Test states of the air_quality.""" - await init_integration(hass) - registry = er.async_get(hass) - - state = hass.states.get("air_quality.home") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_AQI) == AQI_GOOD - assert state.attributes.get(ATTR_PM_10) == 17 - assert state.attributes.get(ATTR_PM_2_5) == 4 - assert state.attributes.get(ATTR_CO) == 252 - assert state.attributes.get(ATTR_SO2) == 4 - assert state.attributes.get(ATTR_NO2) == 7 - assert state.attributes.get(ATTR_OZONE) == 96 - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:emoticon-happy" - assert state.attributes.get("station") == "Test Name 1" - - entry = registry.async_get("air_quality.home") - assert entry - assert entry.unique_id == "123" - - -async def test_air_quality_with_incomplete_data(hass): - """Test states of the air_quality with incomplete data from measuring station.""" - await init_integration(hass, incomplete_data=True) - registry = er.async_get(hass) - - state = hass.states.get("air_quality.home") - assert state - assert state.state == "4" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_AQI) == "foo" - assert state.attributes.get(ATTR_PM_10) is None - assert state.attributes.get(ATTR_PM_2_5) == 4 - assert state.attributes.get(ATTR_CO) == 252 - assert state.attributes.get(ATTR_SO2) == 4 - assert state.attributes.get(ATTR_NO2) == 7 - assert state.attributes.get(ATTR_OZONE) == 96 - assert ( - state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER - ) - assert state.attributes.get(ATTR_ICON) == "mdi:blur" - assert state.attributes.get("station") == "Test Name 1" - - entry = registry.async_get("air_quality.home") - assert entry - assert entry.unique_id == "123" - - -async def test_availability(hass): - """Ensure that we mark the entities unavailable correctly when service causes an error.""" - await init_integration(hass) - - state = hass.states.get("air_quality.home") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "4" - - future = utcnow() + timedelta(minutes=60) - with patch( - "homeassistant.components.gios.Gios._get_all_sensors", - side_effect=ApiError("Unexpected error"), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("air_quality.home") - assert state - assert state.state == STATE_UNAVAILABLE - - future = utcnow() + timedelta(minutes=120) - with patch( - "homeassistant.components.gios.Gios._get_all_sensors", - return_value=json.loads(load_fixture("gios/sensors.json")), - ), patch( - "homeassistant.components.gios.Gios._get_indexes", - return_value=json.loads(load_fixture("gios/indexes.json")), - ): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - state = hass.states.get("air_quality.home") - assert state - assert state.state != STATE_UNAVAILABLE - assert state.state == "4" - - -async def test_migrate_unique_id(hass): - """Test migrate unique_id of the air_quality entity.""" - registry = er.async_get(hass) - - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( - AIR_QUALITY_DOMAIN, - DOMAIN, - 123, - suggested_object_id="home", - disabled_by=None, - ) - - await init_integration(hass) - - entry = registry.async_get("air_quality.home") - assert entry - assert entry.unique_id == "123" diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py index 6b1f829c4d8..21fc9d8bfda 100644 --- a/tests/components/gios/test_config_flow.py +++ b/tests/components/gios/test_config_flow.py @@ -99,7 +99,7 @@ async def test_create_entry(hass): result = await flow.async_step_user(user_input=CONFIG) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == CONFIG[CONF_STATION_ID] + assert result["title"] == "Test Name 1" assert result["data"][CONF_STATION_ID] == CONFIG[CONF_STATION_ID] assert flow.context["unique_id"] == "123" diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py index 08629608cd4..f0b3f660e8d 100644 --- a/tests/components/gios/test_init.py +++ b/tests/components/gios/test_init.py @@ -2,9 +2,11 @@ import json from unittest.mock import patch +from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.components.gios.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er from . import STATIONS @@ -16,7 +18,7 @@ async def test_async_setup_entry(hass): """Test a successful setup entry.""" await init_integration(hass) - state = hass.states.get("air_quality.home") + state = hass.states.get("sensor.home_pm2_5") assert state is not None assert state.state != STATE_UNAVAILABLE assert state.state == "4" @@ -95,3 +97,21 @@ async def test_migrate_device_and_config_entry(hass): config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "123")} ) assert device_entry.id == migrated_device_entry.id + + +async def test_remove_air_quality_entities(hass): + """Test remove air_quality entities from registry.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + AIR_QUALITY_PLATFORM, + DOMAIN, + "123", + suggested_object_id="home", + disabled_by=None, + ) + + await init_integration(hass) + + entry = registry.async_get("air_quality.home") + assert entry is None diff --git a/tests/components/gios/test_sensor.py b/tests/components/gios/test_sensor.py new file mode 100644 index 00000000000..2da3d8e1e8c --- /dev/null +++ b/tests/components/gios/test_sensor.py @@ -0,0 +1,382 @@ +"""Test sensor of GIOS integration.""" +from datetime import timedelta +import json +from unittest.mock import patch + +from gios import ApiError + +from homeassistant.components.gios.const import ( + ATTR_INDEX, + ATTR_STATION, + ATTRIBUTION, + DOMAIN, +) +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + DOMAIN as PLATFORM, + STATE_CLASS_MEASUREMENT, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed, load_fixture +from tests.components.gios import init_integration + + +async def test_sensor(hass): + """Test states of the sensor.""" + await init_integration(hass) + registry = er.async_get(hass) + + state = hass.states.get("sensor.home_c6h6") + assert state + assert state.state == "0" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + + entry = registry.async_get("sensor.home_c6h6") + assert entry + assert entry.unique_id == "123-c6h6" + + state = hass.states.get("sensor.home_co") + assert state + assert state.state == "252" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "dobry" + + entry = registry.async_get("sensor.home_co") + assert entry + assert entry.unique_id == "123-co" + + state = hass.states.get("sensor.home_no2") + assert state + assert state.state == "7" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "dobry" + + entry = registry.async_get("sensor.home_no2") + assert entry + assert entry.unique_id == "123-no2" + + state = hass.states.get("sensor.home_o3") + assert state + assert state.state == "96" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "dobry" + + entry = registry.async_get("sensor.home_o3") + assert entry + assert entry.unique_id == "123-o3" + + state = hass.states.get("sensor.home_pm10") + assert state + assert state.state == "17" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "dobry" + + entry = registry.async_get("sensor.home_pm10") + assert entry + assert entry.unique_id == "123-pm10" + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "dobry" + + entry = registry.async_get("sensor.home_pm2_5") + assert entry + assert entry.unique_id == "123-pm25" + + state = hass.states.get("sensor.home_so2") + assert state + assert state.state == "4" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) == "bardzo dobry" + + entry = registry.async_get("sensor.home_so2") + assert entry + assert entry.unique_id == "123-so2" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == "dobry" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + + entry = registry.async_get("sensor.home_aqi") + assert entry + assert entry.unique_id == "123-aqi" + + +async def test_availability(hass): + """Ensure that we mark the entities unavailable correctly when service causes an error.""" + await init_integration(hass) + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "4" + + future = utcnow() + timedelta(minutes=60) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", + side_effect=ApiError("Unexpected error"), + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == STATE_UNAVAILABLE + + future = utcnow() + timedelta(minutes=120) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", + return_value=json.loads(load_fixture("gios/sensors.json")), + ), patch( + "homeassistant.components.gios.Gios._get_indexes", + return_value={}, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "4" + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_invalid_indexes(hass): + """Test states of the sensor when API returns invalid indexes.""" + await init_integration(hass, invalid_indexes=True) + registry = er.async_get(hass) + + state = hass.states.get("sensor.home_c6h6") + assert state + assert state.state == "0" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_c6h6") + assert entry + assert entry.unique_id == "123-c6h6" + + state = hass.states.get("sensor.home_co") + assert state + assert state.state == "252" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_co") + assert entry + assert entry.unique_id == "123-co" + + state = hass.states.get("sensor.home_no2") + assert state + assert state.state == "7" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_no2") + assert entry + assert entry.unique_id == "123-no2" + + state = hass.states.get("sensor.home_o3") + assert state + assert state.state == "96" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_o3") + assert entry + assert entry.unique_id == "123-o3" + + state = hass.states.get("sensor.home_pm10") + assert state + assert state.state == "17" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_pm10") + assert entry + assert entry.unique_id == "123-pm10" + + state = hass.states.get("sensor.home_pm2_5") + assert state + assert state.state == "4" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_pm2_5") + assert entry + assert entry.unique_id == "123-pm25" + + state = hass.states.get("sensor.home_so2") + assert state + assert state.state == "4" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_STATION) == "Test Name 1" + assert state.attributes.get(ATTR_STATE_CLASS) == STATE_CLASS_MEASUREMENT + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) + assert state.attributes.get(ATTR_ICON) == "mdi:blur" + assert state.attributes.get(ATTR_INDEX) is None + + entry = registry.async_get("sensor.home_so2") + assert entry + assert entry.unique_id == "123-so2" + + state = hass.states.get("sensor.home_aqi") + assert state is None + + +async def test_aqi_sensor_availability(hass): + """Ensure that we mark the AQI sensor unavailable correctly when indexes are invalid.""" + await init_integration(hass) + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "dobry" + + future = utcnow() + timedelta(minutes=60) + with patch( + "homeassistant.components.gios.Gios._get_all_sensors", + return_value=json.loads(load_fixture("gios/sensors.json")), + ), patch( + "homeassistant.components.gios.Gios._get_indexes", + return_value={}, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("sensor.home_aqi") + assert state + assert state.state == STATE_UNAVAILABLE + + +async def test_unique_id_migration(hass): + """Test states of the unique_id migration.""" + registry = er.async_get(hass) + + registry.async_get_or_create( + PLATFORM, + DOMAIN, + "123-pm2.5", + suggested_object_id="home_pm2_5", + disabled_by=None, + ) + + await init_integration(hass) + + entry = registry.async_get("sensor.home_pm2_5") + assert entry + assert entry.unique_id == "123-pm25" diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index a8b44511fb2..f7537db18de 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -382,6 +382,13 @@ DEMO_DEVICES = [ "type": "action.devices.types.LOCK", "willReportState": False, }, + { + "id": "lock.poorly_installed_door", + "name": {"name": "Poorly Installed Door"}, + "traits": ["action.devices.traits.LockUnlock"], + "type": "action.devices.types.LOCK", + "willReportState": False, + }, { "id": "alarm_control_panel.alarm", "name": {"name": "Alarm"}, diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index bc9195264d9..2c3a61b8beb 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -134,8 +134,8 @@ async def test_sync_request(hass_fixture, assistant_client, auth_header): body = await result.json() assert body.get("requestId") == reqid devices = body["payload"]["devices"] - assert sorted([dev["id"] for dev in devices]) == sorted( - [dev["id"] for dev in DEMO_DEVICES] + assert sorted(dev["id"] for dev in devices) == sorted( + dev["id"] for dev in DEMO_DEVICES ) for dev in devices: diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index d6094a771bd..e86156fa614 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -223,9 +223,7 @@ async def test_report_state_all(agents): data = {} with patch.object(config, "async_report_state") as mock: await config.async_report_state_all(data) - assert sorted(mock.mock_calls) == sorted( - [call(data, agent) for agent in agents] - ) + assert sorted(mock.mock_calls) == sorted(call(data, agent) for agent in agents) @pytest.mark.parametrize( @@ -241,7 +239,7 @@ async def test_sync_entities_all(agents, result): side_effect=lambda agent_user_id: agents[agent_user_id], ) as mock: res = await config.async_sync_entities_all() - assert sorted(mock.mock_calls) == sorted([call(agent) for agent in agents]) + assert sorted(mock.mock_calls) == sorted(call(agent) for agent in agents) assert res == result diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e2821d207d5..c57d894c36d 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1042,6 +1042,45 @@ async def test_lock_unlock_lock(hass): assert calls[0].data == {ATTR_ENTITY_ID: "lock.front_door"} +async def test_lock_unlock_unlocking(hass): + """Test LockUnlock trait locking support for lock domain.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + + trt = trait.LockUnlockTrait( + hass, State("lock.front_door", lock.STATE_UNLOCKING), PIN_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isLocked": True} + + +async def test_lock_unlock_lock_jammed(hass): + """Test LockUnlock trait locking support for lock domain that jams.""" + assert helpers.get_google_type(lock.DOMAIN, None) is not None + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN, None, None) + assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN, None) + + trt = trait.LockUnlockTrait( + hass, State("lock.front_door", lock.STATE_JAMMED), PIN_CONFIG + ) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == {"isJammed": True} + + assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {"lock": True}) + + calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) + + await trt.execute(trait.COMMAND_LOCKUNLOCK, PIN_DATA, {"lock": True}, {}) + + assert len(calls) == 1 + assert calls[0].data == {ATTR_ENTITY_ID: "lock.front_door"} + + async def test_lock_unlock_unlock(hass): """Test LockUnlock trait unlocking support for lock domain.""" assert helpers.get_google_type(lock.DOMAIN, None) is not None @@ -1422,13 +1461,6 @@ async def test_fan_speed(hass): "fan.living_room_fan", fan.SPEED_HIGH, attributes={ - "speed_list": [ - fan.SPEED_OFF, - fan.SPEED_LOW, - fan.SPEED_MEDIUM, - fan.SPEED_HIGH, - ], - "speed": "low", "percentage": 33, "percentage_step": 1.0, }, @@ -1437,64 +1469,14 @@ async def test_fan_speed(hass): ) assert trt.sync_attributes() == { - "availableFanSpeeds": { - "ordered": True, - "speeds": [ - { - "speed_name": "off", - "speed_values": [{"speed_synonym": ["stop", "off"], "lang": "en"}], - }, - { - "speed_name": "low", - "speed_values": [ - { - "speed_synonym": ["slow", "low", "slowest", "lowest"], - "lang": "en", - } - ], - }, - { - "speed_name": "medium", - "speed_values": [ - {"speed_synonym": ["medium", "mid", "middle"], "lang": "en"} - ], - }, - { - "speed_name": "high", - "speed_values": [ - { - "speed_synonym": [ - "high", - "max", - "fast", - "highest", - "fastest", - "maximum", - ], - "lang": "en", - } - ], - }, - ], - }, "reversible": False, "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { - "currentFanSpeedSetting": "low", - "on": True, "currentFanSpeedPercent": 33, } - assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeed": "medium"}) - - calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) - await trt.execute(trait.COMMAND_FANSPEED, BASIC_DATA, {"fanSpeed": "medium"}, {}) - - assert len(calls) == 1 - assert calls[0].data == {"entity_id": "fan.living_room_fan", "speed": "medium"} - assert trt.can_execute(trait.COMMAND_FANSPEED, params={"fanSpeedPercent": 10}) calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_PERCENTAGE) @@ -1504,6 +1486,53 @@ async def test_fan_speed(hass): assert calls[0].data == {"entity_id": "fan.living_room_fan", "percentage": 10} +@pytest.mark.parametrize( + "direction_state,direction_call", + [ + (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE), + (fan.DIRECTION_REVERSE, fan.DIRECTION_FORWARD), + (None, fan.DIRECTION_FORWARD), + ], +) +async def test_fan_reverse(hass, direction_state, direction_call): + """Test FanSpeed trait speed control support for fan domain.""" + + calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_DIRECTION) + + trt = trait.FanSpeedTrait( + hass, + State( + "fan.living_room_fan", + fan.SPEED_HIGH, + attributes={ + "percentage": 33, + "percentage_step": 1.0, + "direction": direction_state, + "supported_features": fan.SUPPORT_DIRECTION, + }, + ), + BASIC_CONFIG, + ) + + assert trt.sync_attributes() == { + "reversible": True, + "supportsFanSpeedPercent": True, + } + + assert trt.query_attributes() == { + "currentFanSpeedPercent": 33, + } + + assert trt.can_execute(trait.COMMAND_REVERSE, params={}) + await trt.execute(trait.COMMAND_REVERSE, BASIC_DATA, {}, {}) + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "fan.living_room_fan", + "direction": direction_call, + } + + async def test_climate_fan_speed(hass): """Test FanSpeed trait speed control support for climate domain.""" assert helpers.get_google_type(climate.DOMAIN, None) is not None @@ -1547,7 +1576,6 @@ async def test_climate_fan_speed(hass): ], }, "reversible": False, - "supportsFanSpeedPercent": True, } assert trt.query_attributes() == { diff --git a/tests/components/gree/common.py b/tests/components/gree/common.py index 2c9c295da1c..40403377957 100644 --- a/tests/components/gree/common.py +++ b/tests/components/gree/common.py @@ -62,6 +62,7 @@ def build_device_mock(name="fake-device-1", ipAddress="1.1.1.1", mac="aabbcc1122 horizontal_swing=0, vertical_swing=0, target_temperature=25, + current_temperature=25, power=False, sleep=False, quiet=False, diff --git a/tests/components/gree/test_climate.py b/tests/components/gree/test_climate.py index c062cfc5615..d88f6a6fbf0 100644 --- a/tests/components/gree/test_climate.py +++ b/tests/components/gree/test_climate.py @@ -7,6 +7,7 @@ from greeclimate.exceptions import DeviceNotBoundError, DeviceTimeoutError import pytest from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE, ATTR_HVAC_MODE, ATTR_PRESET_MODE, @@ -379,16 +380,21 @@ async def test_send_power_off_device_timeout(hass, discovery, device, mock_now): @pytest.mark.parametrize( - "units,temperature", [(TEMP_CELSIUS, 25), (TEMP_FAHRENHEIT, 74)] + "units,temperature", [(TEMP_CELSIUS, 26), (TEMP_FAHRENHEIT, 74)] ) async def test_send_target_temperature(hass, discovery, device, units, temperature): """Test for sending target temperature command to the device.""" hass.config.units.temperature_unit = units + + fake_device = device() if units == TEMP_FAHRENHEIT: - device().temperature_units = 1 + fake_device.temperature_units = 1 await async_setup_gree(hass) + # Make sure we're trying to test something that isn't the default + assert fake_device.current_temperature != temperature + assert await hass.services.async_call( DOMAIN, SERVICE_SET_TEMPERATURE, @@ -399,8 +405,13 @@ async def test_send_target_temperature(hass, discovery, device, units, temperatu state = hass.states.get(ENTITY_ID) assert state is not None assert state.attributes.get(ATTR_TEMPERATURE) == temperature + assert ( + state.attributes.get(ATTR_CURRENT_TEMPERATURE) + == fake_device.current_temperature + ) - # Reset config temperature_unit back to CELSIUS, required for additional tests outside this component. + # Reset config temperature_unit back to CELSIUS, required for + # additional tests outside this component. hass.config.units.temperature_unit = TEMP_CELSIUS diff --git a/tests/components/gree/test_switch.py b/tests/components/gree/test_switch.py index 39ad536880c..3347fac00f5 100644 --- a/tests/components/gree/test_switch.py +++ b/tests/components/gree/test_switch.py @@ -1,5 +1,6 @@ """Tests for gree component.""" from greeclimate.exceptions import DeviceTimeoutError +import pytest from homeassistant.components.gree.const import DOMAIN as GREE_DOMAIN from homeassistant.components.switch import DOMAIN @@ -16,7 +17,10 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -ENTITY_ID = f"{DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_LIGHT_PANEL = f"{DOMAIN}.fake_device_1_panel_light" +ENTITY_ID_QUIET = f"{DOMAIN}.fake_device_1_quiet" +ENTITY_ID_FRESH_AIR = f"{DOMAIN}.fake_device_1_fresh_air" +ENTITY_ID_XFAN = f"{DOMAIN}.fake_device_1_xfan" async def async_setup_gree(hass): @@ -26,23 +30,41 @@ async def async_setup_gree(hass): await hass.async_block_till_done() -async def test_send_panel_light_on(hass): +@pytest.mark.parametrize( + "entity", + [ + ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_QUIET, + ENTITY_ID_FRESH_AIR, + ENTITY_ID_XFAN, + ], +) +async def test_send_switch_on(hass, entity): """Test for sending power on command to the device.""" await async_setup_gree(hass) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_ON -async def test_send_panel_light_on_device_timeout(hass, device): +@pytest.mark.parametrize( + "entity", + [ + ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_QUIET, + ENTITY_ID_FRESH_AIR, + ENTITY_ID_XFAN, + ], +) +async def test_send_switch_on_device_timeout(hass, device, entity): """Test for sending power on command to the device with a device timeout.""" device().push_state_update.side_effect = DeviceTimeoutError @@ -51,32 +73,50 @@ async def test_send_panel_light_on_device_timeout(hass, device): assert await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_ON -async def test_send_panel_light_off(hass): +@pytest.mark.parametrize( + "entity", + [ + ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_QUIET, + ENTITY_ID_FRESH_AIR, + ENTITY_ID_XFAN, + ], +) +async def test_send_switch_off(hass, entity): """Test for sending power on command to the device.""" await async_setup_gree(hass) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_OFF -async def test_send_panel_light_toggle(hass): +@pytest.mark.parametrize( + "entity", + [ + ENTITY_ID_LIGHT_PANEL, + ENTITY_ID_QUIET, + ENTITY_ID_FRESH_AIR, + ENTITY_ID_XFAN, + ], +) +async def test_send_switch_toggle(hass, entity): """Test for sending power on command to the device.""" await async_setup_gree(hass) @@ -84,11 +124,11 @@ async def test_send_panel_light_toggle(hass): assert await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_ON @@ -96,11 +136,11 @@ async def test_send_panel_light_toggle(hass): assert await hass.services.async_call( DOMAIN, SERVICE_TOGGLE, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_OFF @@ -108,17 +148,26 @@ async def test_send_panel_light_toggle(hass): assert await hass.services.async_call( DOMAIN, SERVICE_TOGGLE, - {ATTR_ENTITY_ID: ENTITY_ID}, + {ATTR_ENTITY_ID: entity}, blocking=True, ) - state = hass.states.get(ENTITY_ID) + state = hass.states.get(entity) assert state is not None assert state.state == STATE_ON -async def test_panel_light_name(hass): +@pytest.mark.parametrize( + "entity,name", + [ + (ENTITY_ID_LIGHT_PANEL, "Panel Light"), + (ENTITY_ID_QUIET, "Quiet"), + (ENTITY_ID_FRESH_AIR, "Fresh Air"), + (ENTITY_ID_XFAN, "XFan"), + ], +) +async def test_entity_name(hass, entity, name): """Test for name property.""" await async_setup_gree(hass) - state = hass.states.get(ENTITY_ID) - assert state.attributes[ATTR_FRIENDLY_NAME] == "fake-device-1 Panel Light" + state = hass.states.get(entity) + assert state.attributes[ATTR_FRIENDLY_NAME] == f"fake-device-1 {name}" diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 59bde36b46b..8a29274298b 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -17,6 +17,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, + CONF_UNIQUE_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, @@ -32,6 +33,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -77,6 +79,7 @@ CONFIG_ATTRIBUTES = { DOMAIN: { "platform": "group", CONF_ENTITIES: [DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT], + CONF_UNIQUE_ID: "unique_identifier", } } @@ -220,6 +223,11 @@ async def test_attributes(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.attributes[ATTR_ASSUMED_STATE] is True + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(COVER_GROUP) + assert entry + assert entry.unique_id == "unique_identifier" + @pytest.mark.parametrize("config_count", [(CONFIG_TILT_ONLY, 2)]) async def test_cover_that_only_supports_tilt_removed(hass, setup_comp): diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 06ad1b1101b..74275cf0bd2 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -44,6 +44,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -58,6 +59,7 @@ async def test_default_state(hass): "platform": DOMAIN, "entities": ["light.kitchen", "light.bedroom"], "name": "Bedroom Group", + "unique_id": "unique_identifier", } }, ) @@ -77,6 +79,11 @@ async def test_default_state(hass): assert state.attributes.get(ATTR_EFFECT_LIST) is None assert state.attributes.get(ATTR_EFFECT) is None + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("light.bedroom_group") + assert entry + assert entry.unique_id == "unique_identifier" + async def test_state_reporting(hass): """Test the state reporting.""" @@ -1064,7 +1071,7 @@ async def test_invalid_service_calls(hass): """Test invalid service call arguments get discarded.""" add_entities = MagicMock() await group.async_setup_platform( - hass, {"entities": ["light.test1", "light.test2"]}, add_entities + hass, {"name": "test", "entities": ["light.test1", "light.test2"]}, add_entities ) await hass.async_block_till_done() await hass.async_start() diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index 5dd5e4225cc..27962297952 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -49,6 +49,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -73,6 +74,7 @@ async def test_default_state(hass): "platform": DOMAIN, "entities": ["media_player.player_1", "media_player.player_2"], "name": "Media group", + "unique_id": "unique_identifier", } }, ) @@ -89,6 +91,11 @@ async def test_default_state(hass): "media_player.player_2", ] + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("media_player.media_group") + assert entry + assert entry.unique_id == "unique_identifier" + async def test_state_reporting(hass): """Test the state reporting.""" diff --git a/tests/components/growatt_server/test_config_flow.py b/tests/components/growatt_server/test_config_flow.py index cc11c2f8bf2..662448c8118 100644 --- a/tests/components/growatt_server/test_config_flow.py +++ b/tests/components/growatt_server/test_config_flow.py @@ -3,12 +3,20 @@ from copy import deepcopy from unittest.mock import patch from homeassistant import config_entries, data_entry_flow -from homeassistant.components.growatt_server.const import CONF_PLANT_ID, DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.growatt_server.const import ( + CONF_PLANT_ID, + DEFAULT_URL, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from tests.common import MockConfigEntry -FIXTURE_USER_INPUT = {CONF_USERNAME: "username", CONF_PASSWORD: "password"} +FIXTURE_USER_INPUT = { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_URL: DEFAULT_URL, +} GROWATT_PLANT_LIST_RESPONSE = { "data": [ @@ -45,8 +53,8 @@ async def test_show_authenticate_form(hass): assert result["step_id"] == "user" -async def test_incorrect_username(hass): - """Test that it shows the appropriate error when an incorrect username is entered.""" +async def test_incorrect_login(hass): + """Test that it shows the appropriate error when an incorrect username/password/server is entered.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index e75de8741bf..8f7d97213e0 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -1,5 +1,7 @@ """The tests for the hassio component.""" +from unittest.mock import MagicMock, patch + from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO import pytest @@ -275,3 +277,29 @@ async def test_ingress_websocket(hassio_client, build_type, aioclient_mock): assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] + + +async def test_ingress_missing_peername(hassio_client, aioclient_mock, caplog): + """Test hadnling of missing peername.""" + aioclient_mock.get( + "http://127.0.0.1/ingress/lorem/ipsum", + text="test", + ) + + def get_extra_info(_): + return None + + with patch( + "aiohttp.web_request.BaseRequest.transport", + return_value=MagicMock(), + ) as transport_mock: + transport_mock.get_extra_info = get_extra_info + resp = await hassio_client.get( + "/api/hassio_ingress/lorem/ipsum", + headers={"X-Test-Header": "beer"}, + ) + + assert "Can't set forward_for header, missing peername" in caplog.text + + # Check we got right response + assert resp.status == 400 diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index c4f85717cac..7909d8f0239 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -988,11 +988,6 @@ async def test_list_statistic_ids(hass, hass_ws_client, units, attributes, unit) await async_setup_component(hass, "history", {"history": {}}) await async_setup_component(hass, "sensor", {}) await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - hass.states.async_set("sensor.test", 10, attributes=attributes) - await hass.async_block_till_done() - - await hass.async_add_executor_job(trigger_db_commit, hass) - await hass.async_block_till_done() client = await hass_ws_client() await client.send_json({"id": 1, "type": "history/list_statistic_ids"}) @@ -1000,8 +995,11 @@ async def test_list_statistic_ids(hass, hass_ws_client, units, attributes, unit) assert response["success"] assert response["result"] == [] - hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) - await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + hass.states.async_set("sensor.test", 10, attributes=attributes) + await hass.async_block_till_done() + + await hass.async_add_executor_job(trigger_db_commit, hass) + await hass.async_block_till_done() await client.send_json({"id": 2, "type": "history/list_statistic_ids"}) response = await client.receive_json() @@ -1010,8 +1008,27 @@ async def test_list_statistic_ids(hass, hass_ws_client, units, attributes, unit) {"statistic_id": "sensor.test", "unit_of_measurement": unit} ] + hass.data[recorder.DATA_INSTANCE].do_adhoc_statistics(period="hourly", start=now) + await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done) + # Remove the state, statistics will now be fetched from the database + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + await client.send_json({"id": 3, "type": "history/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + {"statistic_id": "sensor.test", "unit_of_measurement": unit} + ] + await client.send_json( - {"id": 3, "type": "history/list_statistic_ids", "statistic_type": "dogs"} + {"id": 4, "type": "history/list_statistic_ids", "statistic_type": "dogs"} + ) + response = await client.receive_json() + assert not response["success"] + + await client.send_json( + {"id": 5, "type": "history/list_statistic_ids", "statistic_type": "mean"} ) response = await client.receive_json() assert response["success"] @@ -1020,16 +1037,7 @@ async def test_list_statistic_ids(hass, hass_ws_client, units, attributes, unit) ] await client.send_json( - {"id": 4, "type": "history/list_statistic_ids", "statistic_type": "mean"} - ) - response = await client.receive_json() - assert response["success"] - assert response["result"] == [ - {"statistic_id": "sensor.test", "unit_of_measurement": unit} - ] - - await client.send_json( - {"id": 5, "type": "history/list_statistic_ids", "statistic_type": "sum"} + {"id": 6, "type": "history/list_statistic_ids", "statistic_type": "sum"} ) response = await client.receive_json() assert response["success"] diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 84ed61322a2..257be293fc0 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -66,7 +66,7 @@ async def test_accessory_cancels_track_state_change_on_stop(hass, hk_driver): async def test_home_accessory(hass, hk_driver): """Test HomeAccessory class.""" entity_id = "sensor.accessory" - entity_id2 = "light.accessory" + entity_id2 = "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum_maximum_maximum_maximum_allowed_length" hass.states.async_set(entity_id, None) hass.states.async_set(entity_id2, STATE_UNAVAILABLE) @@ -94,27 +94,42 @@ async def test_home_accessory(hass, hk_driver): assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" assert serv.get_characteristic(CHAR_MANUFACTURER).value == f"{MANUFACTURER} Light" assert serv.get_characteristic(CHAR_MODEL).value == "Light" - assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" + assert ( + serv.get_characteristic(CHAR_SERIAL_NUMBER).value + == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" + ) acc3 = HomeAccessory( hass, hk_driver, - "Home Accessory", + "Home Accessory that exceeds the maximum maximum maximum maximum maximum maximum length", entity_id2, 3, { - ATTR_MODEL: "Awesome", - ATTR_MANUFACTURER: "Lux Brands", - ATTR_SOFTWARE_VERSION: "0.4.3", - ATTR_INTEGRATION: "luxe", + ATTR_MODEL: "Awesome Model that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_MANUFACTURER: "Lux Brands that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_SOFTWARE_VERSION: "0.4.3 that exceeds the maximum maximum maximum maximum maximum maximum length", + ATTR_INTEGRATION: "luxe that exceeds the maximum maximum maximum maximum maximum maximum length", }, ) assert acc3.available is False serv = acc3.services[0] # SERV_ACCESSORY_INFO - assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" - assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Lux Brands" - assert serv.get_characteristic(CHAR_MODEL).value == "Awesome" - assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" + assert ( + serv.get_characteristic(CHAR_NAME).value + == "Home Accessory that exceeds the maximum maximum maximum maximum " + ) + assert ( + serv.get_characteristic(CHAR_MANUFACTURER).value + == "Lux Brands that exceeds the maximum maximum maximum maximum maxi" + ) + assert ( + serv.get_characteristic(CHAR_MODEL).value + == "Awesome Model that exceeds the maximum maximum maximum maximum m" + ) + assert ( + serv.get_characteristic(CHAR_SERIAL_NUMBER).value + == "light.accessory_that_exceeds_the_maximum_maximum_maximum_maximum" + ) hass.states.async_set(entity_id, "on") await hass.async_block_till_done() diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 491d686162d..1b220153195 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -126,14 +126,28 @@ def test_types(type_name, entity_id, state, attrs, config): "Window", "cover.set_position", "open", - {ATTR_DEVICE_CLASS: "window", ATTR_SUPPORTED_FEATURES: 4}, + { + ATTR_DEVICE_CLASS: "window", + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION, + }, + ), + ( + "WindowCovering", + "cover.set_position", + "open", + {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION}, + ), + ( + "WindowCovering", + "cover.tilt", + "open", + {ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_TILT_POSITION}, ), - ("WindowCovering", "cover.set_position", "open", {ATTR_SUPPORTED_FEATURES: 4}), ( "WindowCoveringBasic", "cover.open_window", "open", - {ATTR_SUPPORTED_FEATURES: 3}, + {ATTR_SUPPORTED_FEATURES: (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE)}, ), ], ) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 5e9ea4fd4b6..138c8fd8209 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -35,11 +35,13 @@ from homeassistant.components.homekit.const import ( HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_START, + SERVICE_HOMEKIT_UNPAIR, ) from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_DEVICE_ID, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_IP_ADDRESS, @@ -52,7 +54,7 @@ from homeassistant.const import ( SERVICE_RELOAD, STATE_ON, ) -from homeassistant.core import State +from homeassistant.core import HomeAssistantError, State from homeassistant.helpers import device_registry from homeassistant.helpers.entityfilter import ( CONF_EXCLUDE_DOMAINS, @@ -156,7 +158,9 @@ async def test_setup_min(hass, mock_zeroconf): ) entry.add_to_hass(hass) - with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() assert await hass.config_entries.async_setup(entry.entry_id) @@ -166,7 +170,7 @@ async def test_setup_min(hass, mock_zeroconf): hass, BRIDGE_NAME, DEFAULT_PORT, - None, + "1.2.3.4", ANY, ANY, {}, @@ -249,7 +253,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): hass, BRIDGE_NAME, DEFAULT_PORT, - None, + IP_ADDRESS, True, {}, {}, @@ -262,10 +266,7 @@ async def test_homekit_setup(hass, hk_driver, mock_zeroconf): hass.states.async_set("light.demo", "on") hass.states.async_set("light.demo2", "on") zeroconf_mock = MagicMock() - with patch( - f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver - ) as mock_driver, patch("homeassistant.util.get_local_ip") as mock_ip: - mock_ip.return_value = IP_ADDRESS + with patch(f"{PATH_HOMEKIT}.HomeDriver", return_value=hk_driver) as mock_driver: await hass.async_add_executor_job(homekit.setup, zeroconf_mock) path = get_persist_fullpath_for_entry_id(hass, entry.entry_id) @@ -435,10 +436,12 @@ async def test_homekit_remove_accessory(hass, mock_zeroconf): homekit.driver = "driver" homekit.bridge = _mock_pyhap_bridge() - homekit.bridge.accessories = {"light.demo": "acc"} + acc_mock = MagicMock() + homekit.bridge.accessories = {6: acc_mock} - acc = homekit.remove_bridge_accessory("light.demo") - assert acc == "acc" + acc = homekit.remove_bridge_accessory(6) + assert acc is acc_mock + assert acc_mock.async_stop.called assert len(homekit.bridge.accessories) == 0 @@ -628,7 +631,211 @@ async def test_homekit_stop(hass): async def test_homekit_reset_accessories(hass, mock_zeroconf): - """Test adding too many accessories to HomeKit.""" + """Test resetting HomeKit accessories.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch( + f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" + ) as mock_run, patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 2 + assert mock_add_accessory.called + assert mock_run.called + homekit.status = STATUS_READY + + +async def test_homekit_unpair(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + formatted_mac = device_registry.format_mac(state.mac) + hk_bridge_dev = device_reg.async_get_device( + {}, {(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)} + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: hk_bridge_dev.id}, + blocking=True, + ) + await hass.async_block_till_done() + assert state.paired_clients == {} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_missing_device_id(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with invalid device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: "notvalid"}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + +async def test_homekit_unpair_not_homekit_device(hass, device_reg, mock_zeroconf): + """Test unpairing HomeKit accessories with a non-homekit device id.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + not_homekit_entry = MockConfigEntry( + domain="not_homekit", data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + device_entry = device_reg.async_get_or_create( + config_entry_id=not_homekit_entry.entry_id, + sw_version="0.16.0", + model="Powerwall 2", + manufacturer="Tesla", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + state = homekit.driver.state + state.paired_clients = {"client1": "any"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_UNPAIR, + {ATTR_DEVICE_ID: device_entry.id}, + blocking=True, + ) + await hass.async_block_till_done() + state.paired_clients = {"client1": "any"} + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_not_supported(hass, mock_zeroconf): + """Test resetting HomeKit accessories with an unsupported entity.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 2 + assert not mock_add_accessory.called + assert len(homekit.bridge.accessories) == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state goes missing.""" await async_setup_component(hass, "persistent_notification", {}) entry = MockConfigEntry( domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} @@ -642,11 +849,15 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): "pyhap.accessory_driver.AccessoryDriver.config_changed" ) as hk_driver_config_changed, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) + acc_mock = MagicMock() + acc_mock.entity_id = entity_id aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) - homekit.bridge.accessories = {aid: "acc"} + homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING await hass.services.async_call( @@ -657,11 +868,186 @@ async def test_homekit_reset_accessories(hass, mock_zeroconf): ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 - assert mock_add_accessory.called + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_accessories_not_bridged(hass, mock_zeroconf): + """Test resetting HomeKit accessories when the state is not bridged.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory.Bridge.add_accessory" + ) as mock_add_accessory, patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch.object( + homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 + ): + await async_init_entry(hass, entry) + + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) + homekit.bridge.accessories = {aid: acc_mock} + homekit.status = STATUS_RUNNING + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.not_bridged"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + assert not mock_add_accessory.called + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory(hass, mock_zeroconf): + """Test resetting HomeKit single accessory.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + hass.states.async_set("light.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch( + f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" + ) as mock_run: + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_run.called + assert hk_driver_config_changed.call_count == 1 homekit.status = STATUS_READY +async def test_homekit_reset_single_accessory_unsupported(hass, mock_zeroconf): + """Test resetting HomeKit single accessory with an unsupported entity.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "not_supported.demo" + hass.states.async_set("not_supported.demo", "on") + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_state_missing(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the state goes missing.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + +async def test_homekit_reset_single_accessory_no_match(hass, mock_zeroconf): + """Test resetting HomeKit single accessory when the entity id does not match.""" + await async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.demo" + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( + "pyhap.accessory_driver.AccessoryDriver.config_changed" + ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ): + await async_init_entry(hass, entry) + + homekit.status = STATUS_RUNNING + acc_mock = MagicMock() + acc_mock.entity_id = entity_id + homekit.driver.accessory = acc_mock + + await hass.services.async_call( + DOMAIN, + SERVICE_HOMEKIT_RESET_ACCESSORY, + {ATTR_ENTITY_ID: "light.no_match"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hk_driver_config_changed.call_count == 0 + homekit.status = STATUS_STOPPED + + async def test_homekit_too_many_accessories(hass, hk_driver, caplog, mock_zeroconf): """Test adding too many accessories to HomeKit.""" entry = await async_init_integration(hass) @@ -842,7 +1228,9 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): ) entry.add_to_hass(hass) - with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): mock_homekit.return_value = homekit = Mock() type(homekit).async_start = AsyncMock() assert await async_setup_component( @@ -854,7 +1242,7 @@ async def test_yaml_updates_update_config_entry_for_name(hass, mock_zeroconf): hass, BRIDGE_NAME, 12345, - None, + "1.2.3.4", ANY, ANY, {}, @@ -1109,7 +1497,9 @@ async def test_reload(hass, mock_zeroconf): ) entry.add_to_hass(hass) - with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit: + with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit, patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" + ): mock_homekit.return_value = homekit = Mock() assert await async_setup_component( hass, "homekit", {"homekit": {CONF_NAME: "reloadable", CONF_PORT: 12345}} @@ -1120,7 +1510,7 @@ async def test_reload(hass, mock_zeroconf): hass, "reloadable", 12345, - None, + "1.2.3.4", ANY, False, {}, @@ -1142,6 +1532,8 @@ async def test_reload(hass, mock_zeroconf): f"{PATH_HOMEKIT}.get_accessory" ), patch( "pyhap.accessory_driver.AccessoryDriver.async_start" + ), patch( + "homeassistant.components.network.async_get_source_ip", return_value="1.2.3.4" ): mock_homekit2.return_value = homekit = Mock() await hass.services.async_call( @@ -1156,7 +1548,7 @@ async def test_reload(hass, mock_zeroconf): hass, "reloadable", 45678, - None, + "1.2.3.4", ANY, False, {}, @@ -1202,6 +1594,36 @@ async def test_homekit_start_in_accessory_mode( assert homekit.status == STATUS_RUNNING +async def test_homekit_start_in_accessory_mode_unsupported_entity( + hass, hk_driver, mock_zeroconf, device_reg, caplog +): + """Test HomeKit start method in accessory mode with an unsupported entity.""" + entry = await async_init_integration(hass) + + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + homekit.bridge = Mock() + homekit.bridge.accessories = [] + homekit.driver = hk_driver + homekit.driver.accessory = Accessory(hk_driver, "any") + + hass.states.async_set("notsupported.demo", "on") + + with patch(f"{PATH_HOMEKIT}.HomeKit.add_bridge_accessory") as mock_add_acc, patch( + f"{PATH_HOMEKIT}.show_setup_message" + ) as mock_setup_msg, patch( + "pyhap.accessory_driver.AccessoryDriver.async_start" + ) as hk_driver_start: + await homekit.async_start() + + await hass.async_block_till_done() + assert not mock_add_acc.called + assert not mock_setup_msg.called + assert not hk_driver_start.called + assert homekit.status == STATUS_WAIT + assert "entity not supported" in caplog.text + + async def test_homekit_start_in_accessory_mode_missing_entity( hass, hk_driver, mock_zeroconf, device_reg, caplog ): diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 354db900470..b9df572a699 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -696,21 +696,21 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): char = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char - assert char.value == 0 + assert char.value is None service2 = acc.get_service(SERV_STATELESS_PROGRAMMABLE_SWITCH) assert service2 char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char2 - assert char2.value == 0 + assert char2.value is None hass.states.async_set( doorbell_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None char.set_value(True) char2.set_value(True) @@ -718,8 +718,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): doorbell_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: DEVICE_CLASS_OCCUPANCY} ) await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None # Ensure we do not throw when the linked # doorbell sensor is removed @@ -727,8 +727,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() - assert char.value == 0 - assert char2.value == 0 + assert char.value is None + assert char2.value is None async def test_camera_with_a_missing_linked_doorbell_sensor(hass, run_driver, events): diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 8514801687c..89407edfbef 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -18,6 +18,8 @@ from homeassistant.components.homekit.const import ( HK_DOOR_CLOSING, HK_DOOR_OPEN, HK_DOOR_OPENING, + PROP_MAX_VALUE, + PROP_MIN_VALUE, ) from homeassistant.components.homekit.type_covers import ( GarageDoorOpener, @@ -133,7 +135,9 @@ async def test_windowcovering_set_cover_position(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" - hass.states.async_set(entity_id, None) + hass.states.async_set( + entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION} + ) await hass.async_block_till_done() acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) await acc.run() @@ -145,31 +149,51 @@ async def test_windowcovering_set_cover_position(hass, hk_driver, events): assert acc.char_current_position.value == 0 assert acc.char_target_position.value == 0 - hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) + hass.states.async_set( + entity_id, + STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION, ATTR_CURRENT_POSITION: None}, + ) await hass.async_block_till_done() assert acc.char_current_position.value == 0 assert acc.char_target_position.value == 0 assert acc.char_position_state.value == 2 - hass.states.async_set(entity_id, STATE_OPENING, {ATTR_CURRENT_POSITION: 60}) + hass.states.async_set( + entity_id, + STATE_OPENING, + {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION, ATTR_CURRENT_POSITION: 60}, + ) await hass.async_block_till_done() assert acc.char_current_position.value == 60 assert acc.char_target_position.value == 60 assert acc.char_position_state.value == 1 - hass.states.async_set(entity_id, STATE_OPENING, {ATTR_CURRENT_POSITION: 70.0}) + hass.states.async_set( + entity_id, + STATE_OPENING, + {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION, ATTR_CURRENT_POSITION: 70.0}, + ) await hass.async_block_till_done() assert acc.char_current_position.value == 70 assert acc.char_target_position.value == 70 assert acc.char_position_state.value == 1 - hass.states.async_set(entity_id, STATE_CLOSING, {ATTR_CURRENT_POSITION: 50}) + hass.states.async_set( + entity_id, + STATE_CLOSING, + {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION, ATTR_CURRENT_POSITION: 50}, + ) await hass.async_block_till_done() assert acc.char_current_position.value == 50 assert acc.char_target_position.value == 50 assert acc.char_position_state.value == 0 - hass.states.async_set(entity_id, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) + hass.states.async_set( + entity_id, + STATE_OPEN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_POSITION, ATTR_CURRENT_POSITION: 50}, + ) await hass.async_block_till_done() assert acc.char_current_position.value == 50 assert acc.char_target_position.value == 50 @@ -283,6 +307,27 @@ async def test_windowcovering_cover_set_tilt(hass, hk_driver, events): assert events[-1].data[ATTR_VALUE] == 75 +async def test_windowcovering_tilt_only(hass, hk_driver, events): + """Test we lock the window covering closed when its tilt only.""" + entity_id = "cover.window" + + hass.states.async_set( + entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION} + ) + await hass.async_block_till_done() + acc = WindowCovering(hass, hk_driver, "Cover", entity_id, 2, None) + await acc.run() + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 14 # WindowCovering + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_target_position.properties[PROP_MIN_VALUE] == 0 + assert acc.char_target_position.properties[PROP_MAX_VALUE] == 0 + + async def test_windowcovering_open_close(hass, hk_driver, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53d6ee02be6..f75e6bf19ac 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -8,9 +8,14 @@ from homeassistant.components.homekit.type_lights import Light from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_RGB, + COLOR_MODE_XY, DOMAIN, ) from homeassistant.const import ( @@ -39,40 +44,44 @@ async def test_light_basic(hass, hk_driver, events): assert acc.aid == 1 assert acc.category == 5 # Lightbulb - assert acc.char_on.value + assert acc.char_on_primary.value await acc.run() await hass.async_block_till_done() - assert acc.char_on.value == 1 + assert acc.char_on_primary.value == 1 hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) await hass.async_block_till_done() - assert acc.char_on.value == 0 + assert acc.char_on_primary.value == 0 hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() - assert acc.char_on.value == 0 + assert acc.char_on_primary.value == 0 hass.states.async_remove(entity_id) await hass.async_block_till_done() - assert acc.char_on.value == 0 + assert acc.char_on_primary.value == 0 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") call_turn_off = async_mock_service(hass, DOMAIN, "turn_off") - char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] + char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1} + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + } ] }, "mock_addr", ) - await hass.async_add_executor_job(acc.char_on.client_update_value, 1) + await hass.async_add_executor_job(acc.char_on_primary.client_update_value, 1) await hass.async_block_till_done() assert call_turn_on assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id @@ -85,7 +94,11 @@ async def test_light_basic(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 0} + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 0, + } ] }, "mock_addr", @@ -115,17 +128,17 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness.value != 0 - char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] - char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness_primary.value != 0 + char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] + char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness.value == 100 + assert acc.char_brightness_primary.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness.value == 40 + assert acc.char_brightness_primary.value == 40 # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") @@ -134,10 +147,14 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_primary_iid, HAP_REPR_VALUE: 20, }, ] @@ -156,10 +173,14 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_primary_iid, HAP_REPR_VALUE: 40, }, ] @@ -178,10 +199,14 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_primary_iid, HAP_REPR_VALUE: 0, }, ] @@ -198,24 +223,24 @@ async def test_light_brightness(hass, hk_driver, events, supported_color_modes): # in update_state hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness.value == 1 + assert acc.char_brightness_primary.value == 1 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) await hass.async_block_till_done() - assert acc.char_brightness.value == 100 + assert acc.char_brightness_primary.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) await hass.async_block_till_done() - assert acc.char_brightness.value == 1 + assert acc.char_brightness_primary.value == 1 # Ensure floats are handled hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) await hass.async_block_till_done() - assert acc.char_brightness.value == 22 + assert acc.char_brightness_primary.value == 22 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) await hass.async_block_till_done() - assert acc.char_brightness.value == 43 + assert acc.char_brightness_primary.value == 43 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) await hass.async_block_till_done() - assert acc.char_brightness.value == 1 + assert acc.char_brightness_primary.value == 1 async def test_light_color_temperature(hass, hk_driver, events): @@ -266,7 +291,12 @@ async def test_light_color_temperature(hass, hk_driver, events): @pytest.mark.parametrize( - "supported_color_modes", [["ct", "hs"], ["ct", "rgb"], ["ct", "xy"]] + "supported_color_modes", + [ + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS], + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_RGB], + [COLOR_MODE_COLOR_TEMP, COLOR_MODE_XY], + ], ) async def test_light_color_temperature_and_rgb_color( hass, hk_driver, events, supported_color_modes @@ -280,29 +310,93 @@ async def test_light_color_temperature_and_rgb_color( { ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_COLOR_TEMP: 190, + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: COLOR_MODE_RGB, ATTR_HS_COLOR: (260, 90), }, ) await hass.async_block_till_done() - acc = Light(hass, hk_driver, "Light", entity_id, 2, None) + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) assert acc.char_hue.value == 260 assert acc.char_saturation.value == 90 + assert acc.char_on_primary.value == 1 + assert acc.char_on_secondary.value == 0 + assert acc.char_brightness_primary.value == 100 + assert acc.char_brightness_secondary.value == 100 - assert not hasattr(acc, "char_color_temperature") + assert hasattr(acc, "char_color_temperature") - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_COLOR_TEMP: 224, + ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, + ATTR_BRIGHTNESS: 127, + }, + ) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() - assert acc.char_hue.value == 27 - assert acc.char_saturation.value == 27 + assert acc.char_color_temperature.value == 224 + assert acc.char_on_primary.value == 0 + assert acc.char_on_secondary.value == 1 + assert acc.char_brightness_primary.value == 50 + assert acc.char_brightness_secondary.value == 50 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_COLOR_TEMP: 352, + ATTR_COLOR_MODE: COLOR_MODE_COLOR_TEMP, + }, + ) await hass.async_block_till_done() await acc.run() await hass.async_block_till_done() - assert acc.char_hue.value == 28 - assert acc.char_saturation.value == 61 + assert acc.char_color_temperature.value == 352 + assert acc.char_on_primary.value == 0 + assert acc.char_on_secondary.value == 1 + hk_driver.add_accessory(acc) + + char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] + char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] + char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_hue_iid, + HAP_REPR_VALUE: 145, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_saturation_iid, + HAP_REPR_VALUE: 75, + }, + ] + }, + "mock_addr", + ) + assert acc.char_hue.value == 145 + assert acc.char_saturation.value == 75 + + hk_driver.set_characteristics( + { + HAP_REPR_CHARS: [ + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_color_temperature_iid, + HAP_REPR_VALUE: 200, + }, + ] + }, + "mock_addr", + ) + assert acc.char_color_temperature.value == 200 @pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) @@ -382,13 +476,13 @@ async def test_light_restore(hass, hk_driver, events): hk_driver.add_accessory(acc) assert acc.category == 5 # Lightbulb - assert acc.chars == [] - assert acc.char_on.value == 0 + assert acc.chars_primary == [] + assert acc.char_on_primary.value == 0 acc = Light(hass, hk_driver, "Light", "light.all_info_set", 2, None) assert acc.category == 5 # Lightbulb - assert acc.chars == ["Brightness"] - assert acc.char_on.value == 0 + assert acc.chars_primary == ["Brightness"] + assert acc.char_on_primary.value == 0 async def test_light_set_brightness_and_color(hass, hk_driver, events): @@ -409,19 +503,19 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness.value != 0 - char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] - char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness_primary.value != 0 + char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] + char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID] char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness.value == 100 + assert acc.char_brightness_primary.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness.value == 40 + assert acc.char_brightness_primary.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) await hass.async_block_till_done() @@ -434,10 +528,14 @@ async def test_light_set_brightness_and_color(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_primary_iid, HAP_REPR_VALUE: 20, }, { @@ -485,18 +583,18 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): # Initial value can be anything but 0. If it is 0, it might cause HomeKit to set the # brightness to 100 when turning on a light on a freshly booted up server. - assert acc.char_brightness.value != 0 - char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID] - char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID] + assert acc.char_brightness_primary.value != 0 + char_on_primary_iid = acc.char_on_primary.to_HAP()[HAP_REPR_IID] + char_brightness_primary_iid = acc.char_brightness_primary.to_HAP()[HAP_REPR_IID] char_color_temperature_iid = acc.char_color_temperature.to_HAP()[HAP_REPR_IID] await acc.run() await hass.async_block_till_done() - assert acc.char_brightness.value == 100 + assert acc.char_brightness_primary.value == 100 hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) await hass.async_block_till_done() - assert acc.char_brightness.value == 40 + assert acc.char_brightness_primary.value == 40 hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: (224.14)}) await hass.async_block_till_done() @@ -508,10 +606,14 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, events): hk_driver.set_characteristics( { HAP_REPR_CHARS: [ - {HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1}, { HAP_REPR_AID: acc.aid, - HAP_REPR_IID: char_brightness_iid, + HAP_REPR_IID: char_on_primary_iid, + HAP_REPR_VALUE: 1, + }, + { + HAP_REPR_AID: acc.aid, + HAP_REPR_IID: char_brightness_primary_iid, HAP_REPR_VALUE: 20, }, { diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index b2bb9b4736e..e47f4dfac71 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -3,7 +3,12 @@ import pytest from homeassistant.components.homekit.const import ATTR_VALUE from homeassistant.components.homekit.type_locks import Lock -from homeassistant.components.lock import DOMAIN +from homeassistant.components.lock import ( + DOMAIN, + STATE_JAMMED, + STATE_LOCKING, + STATE_UNLOCKING, +) from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, @@ -37,11 +42,26 @@ async def test_lock_unlock(hass, hk_driver, events): assert acc.char_current_state.value == 1 assert acc.char_target_state.value == 1 + hass.states.async_set(entity_id, STATE_LOCKING) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 1 + hass.states.async_set(entity_id, STATE_UNLOCKED) await hass.async_block_till_done() assert acc.char_current_state.value == 0 assert acc.char_target_state.value == 0 + hass.states.async_set(entity_id, STATE_UNLOCKING) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_JAMMED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 2 + assert acc.char_target_state.value == 0 + hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == 3 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index b95903d3e3f..33cac7bcf8a 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -242,7 +242,7 @@ async def test_media_player_television(hass, hk_driver, events, caplog): hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 5"}) await hass.async_block_till_done() assert acc.char_input_source.value == 0 - assert caplog.records[-2].levelname == "WARNING" + assert caplog.records[-2].levelname == "DEBUG" # Set from HomeKit call_turn_on = async_mock_service(hass, DOMAIN, "turn_on") diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index e69ebfb29fb..ee71d7f4e3c 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -3,8 +3,10 @@ from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, + DOMAIN as HOMEKIT_DOMAIN, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, KEY_ARROW_RIGHT, + SERVICE_HOMEKIT_RESET_ACCESSORY, ) from homeassistant.components.homekit.type_remotes import ActivityRemote from homeassistant.components.remote import ( @@ -146,3 +148,19 @@ async def test_activity_remote(hass, hk_driver, events, caplog): assert len(events) == 1 assert events[0].data[ATTR_KEY_NAME] == KEY_ARROW_RIGHT + + call_reset_accessory = async_mock_service( + hass, HOMEKIT_DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY + ) + # A wild source appears - The accessory should rebuild itself + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Amazon TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], + }, + ) + await hass.async_block_till_done() + assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index 19b8b5720e2..d1ce830a0e2 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -17,6 +17,8 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, + STATE_ALARM_ARMING, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, @@ -79,7 +81,7 @@ async def test_switch_set_state(hass, hk_driver, events): call_arm_night = async_mock_service(hass, DOMAIN, "alarm_arm_night") call_disarm = async_mock_service(hass, DOMAIN, "alarm_disarm") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_arm_home assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id @@ -88,7 +90,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 1 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 1) + acc.char_target_state.client_update_value(1) await hass.async_block_till_done() assert call_arm_away assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id @@ -97,7 +99,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 2) + acc.char_target_state.client_update_value(2) await hass.async_block_till_done() assert call_arm_night assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id @@ -106,7 +108,7 @@ async def test_switch_set_state(hass, hk_driver, events): assert len(events) == 3 assert events[-1].data[ATTR_VALUE] is None - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 3) + acc.char_target_state.client_update_value(3) await hass.async_block_till_done() assert call_disarm assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id @@ -128,7 +130,7 @@ async def test_no_alarm_code(hass, hk_driver, config, events): # Set from HomeKit call_arm_home = async_mock_service(hass, DOMAIN, "alarm_arm_home") - await hass.async_add_executor_job(acc.char_target_state.client_update_value, 0) + acc.char_target_state.client_update_value(0) await hass.async_block_till_done() assert call_arm_home assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id @@ -138,6 +140,57 @@ async def test_no_alarm_code(hass, hk_driver, config, events): assert events[-1].data[ATTR_VALUE] is None +async def test_arming(hass, hk_driver, events): + """Test to make sure arming sets the right state.""" + entity_id = "alarm_control_panel.test" + + hass.states.async_set(entity_id, None) + + acc = SecuritySystem(hass, hk_driver, "SecuritySystem", entity_id, 2, {}) + await acc.run() + await hass.async_block_till_done() + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_HOME) + await hass.async_block_till_done() + assert acc.char_target_state.value == 0 + assert acc.char_current_state.value == 0 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_VACATION) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_NIGHT) + await hass.async_block_till_done() + assert acc.char_target_state.value == 2 + assert acc.char_current_state.value == 2 + + hass.states.async_set(entity_id, STATE_ALARM_ARMING) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_DISARMED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 4 + + async def test_supported_states(hass, hk_driver, events): """Test different supported states.""" code = "1234" diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 2ce0acfc8bc..455f7a6141a 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -84,7 +84,6 @@ async def test_outlet_set_state(hass, hk_driver, events): ("automation.test", {}), ("input_boolean.test", {}), ("remote.test", {}), - ("script.test", {}), ("switch.test", {}), ], ) @@ -340,8 +339,9 @@ async def test_reset_switch(hass, hk_driver, events): assert len(events) == 1 -async def test_reset_switch_reload(hass, hk_driver, events): - """Test reset switch after script reload.""" +async def test_script_switch(hass, hk_driver, events): + """Test if script switch accessory is reset correctly.""" + domain = "script" entity_id = "script.test" hass.states.async_set(entity_id, None) @@ -350,8 +350,28 @@ async def test_reset_switch_reload(hass, hk_driver, events): await acc.run() await hass.async_block_till_done() - assert acc.activate_only is False + assert acc.activate_only is True + assert acc.char_on.value is False - hass.states.async_set(entity_id, None) + call_turn_on = async_mock_service(hass, domain, "test") + call_turn_off = async_mock_service(hass, domain, "turn_off") + + await hass.async_add_executor_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert acc.char_on.value is True + assert call_turn_on + assert call_turn_on[0].data == {} + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) await hass.async_block_till_done() assert acc.char_on.value is False + assert len(events) == 1 + assert not call_turn_off + + await hass.async_add_executor_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 96dbd3b0718..94f3aabc12a 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -60,7 +60,7 @@ async def test_ecobee3_setup(hass): assert climate_state.attributes["max_humidity"] == 50 climate_sensor = entity_registry.async_get("sensor.homew_current_temperature") - assert climate_sensor.unique_id == "homekit-123456789012-aid:1-sid:16-cid:16" + assert climate_sensor.unique_id == "homekit-123456789012-aid:1-sid:16-cid:19" occ1 = entity_registry.async_get("binary_sensor.kitchen") assert occ1.unique_id == "homekit-AB1C-56" diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py index db72aad7541..1761edb3c8c 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_p1eu.py @@ -1,5 +1,6 @@ """Make sure that existing Koogeek P1EU support isn't broken.""" +from homeassistant.const import POWER_WATT from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.components.homekit_controller.common import ( @@ -37,7 +38,7 @@ async def test_koogeek_p1eu_setup(hass): # Assert the power sensor is detected entry = entity_registry.async_get("sensor.koogeek_p1_a00aa0_real_time_energy") - assert entry.unique_id == "homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:21" + assert entry.unique_id == "homekit-EUCP03190xxxxx48-aid:1-sid:21-cid:22" helper = Helper( hass, @@ -48,6 +49,7 @@ async def test_koogeek_p1eu_setup(hass): ) state = await helper.poll_and_get_state() assert state.attributes["friendly_name"] == "Koogeek-P1-A00AA0 - Real Time Energy" + assert state.attributes["unit_of_measurement"] == POWER_WATT # The sensor and switch should be part of the same device assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py new file mode 100644 index 00000000000..768959e0331 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_sw2.py @@ -0,0 +1,66 @@ +""" +Make sure that existing Koogeek SW2 is enumerated correctly. + +This Koogeek device has a custom power sensor that extra handling. + +It should have 2 entities - the actual switch and a sensor for power usage. +""" + +from homeassistant.const import POWER_WATT +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_koogeek_ls1_setup(hass): + """Test that a Koogeek LS1 can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "koogeek_sw2.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + + # Assert that the switch entity is correctly added to the entity registry + entry = entity_registry.async_get("switch.koogeek_sw2_187a91") + assert entry.unique_id == "homekit-CNNT061751001372-8" + + helper = Helper( + hass, "switch.koogeek_sw2_187a91", pairing, accessories[0], config_entry + ) + state = await helper.poll_and_get_state() + + # Assert that the friendly name is detected correctly + assert state.attributes["friendly_name"] == "Koogeek-SW2-187A91" + + device_registry = dr.async_get(hass) + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Koogeek" + assert device.name == "Koogeek-SW2-187A91" + assert device.model == "KH02CN" + assert device.sw_version == "1.0.3" + assert device.via_device_id is None + + # Assert that the power sensor entity is correctly added to the entity registry + entry = entity_registry.async_get("sensor.koogeek_sw2_187a91_real_time_energy") + assert entry.unique_id == "homekit-CNNT061751001372-aid:1-sid:14-cid:18" + + helper = Helper( + hass, + "sensor.koogeek_sw2_187a91_real_time_energy", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + + # Assert that the friendly name is detected correctly + assert state.attributes["friendly_name"] == "Koogeek-SW2-187A91 - Real Time Energy" + assert state.attributes["unit_of_measurement"] == POWER_WATT + + device_registry = dr.async_get(hass) + + assert device.id == entry.device_id diff --git a/tests/components/homekit_controller/specific_devices/test_mysa_living.py b/tests/components/homekit_controller/specific_devices/test_mysa_living.py new file mode 100644 index 00000000000..ea1c1084071 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_mysa_living.py @@ -0,0 +1,91 @@ +"""Make sure that Mysa Living is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_mysa_living_setup(hass): + """Test that the accessory can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "mysa_living.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Check that the switch entity is handled correctly + + entry = entity_registry.async_get("sensor.mysa_85dda9_current_humidity") + assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:27" + + helper = Helper( + hass, + "sensor.mysa_85dda9_current_humidity", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Humidity" + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "Empowered Homes Inc." + assert device.name == "Mysa-85dda9" + assert device.model == "v1" + assert device.sw_version == "2.8.1" + assert device.via_device_id is None + + # Assert the humidifier is detected + entry = entity_registry.async_get("sensor.mysa_85dda9_current_temperature") + assert entry.unique_id == "homekit-AAAAAAA000-aid:1-sid:20-cid:25" + + helper = Helper( + hass, + "sensor.mysa_85dda9_current_temperature", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9 - Current Temperature" + + # The sensor should be part of the same device + assert entry.device_id == device.id + + # Assert the light is detected + entry = entity_registry.async_get("light.mysa_85dda9") + assert entry.unique_id == "homekit-AAAAAAA000-40" + + helper = Helper( + hass, + "light.mysa_85dda9", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9" + + # The light should be part of the same device + assert entry.device_id == device.id + + # Assert the climate entity is detected + entry = entity_registry.async_get("climate.mysa_85dda9") + assert entry.unique_id == "homekit-AAAAAAA000-20" + + helper = Helper( + hass, + "climate.mysa_85dda9", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "Mysa-85dda9" + + # The light should be part of the same device + assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py new file mode 100644 index 00000000000..6968c62257f --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_vocolinc_flowerbud.py @@ -0,0 +1,92 @@ +"""Make sure that Vocolinc Flowerbud is enumerated properly.""" + +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_vocolinc_flowerbud_setup(hass): + """Test that a Vocolinc Flowerbud can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "vocolinc_flowerbud.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + # Check that the switch entity is handled correctly + + entry = entity_registry.async_get("number.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:38" + + helper = Helper( + hass, "number.vocolinc_flowerbud_0d324b", pairing, accessories[0], config_entry + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "VOCOlinc" + assert device.name == "VOCOlinc-Flowerbud-0d324b" + assert device.model == "Flowerbud" + assert device.sw_version == "3.121.2" + assert device.via_device_id is None + + # Assert the humidifier is detected + entry = entity_registry.async_get("humidifier.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-30" + + helper = Helper( + hass, + "humidifier.vocolinc_flowerbud_0d324b", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + # The sensor and switch should be part of the same device + assert entry.device_id == device.id + + # Assert the light is detected + entry = entity_registry.async_get("light.vocolinc_flowerbud_0d324b") + assert entry.unique_id == "homekit-AM01121849000327-9" + + helper = Helper( + hass, + "light.vocolinc_flowerbud_0d324b", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert state.attributes["friendly_name"] == "VOCOlinc-Flowerbud-0d324b" + + # The sensor and switch should be part of the same device + assert entry.device_id == device.id + + # Assert the humidity sensory is detected + entry = entity_registry.async_get( + "sensor.vocolinc_flowerbud_0d324b_current_humidity" + ) + assert entry.unique_id == "homekit-AM01121849000327-aid:1-sid:30-cid:33" + + helper = Helper( + hass, + "sensor.vocolinc_flowerbud_0d324b_current_humidity", + pairing, + accessories[0], + config_entry, + ) + state = await helper.poll_and_get_state() + assert ( + state.attributes["friendly_name"] + == "VOCOlinc-Flowerbud-0d324b - Current Humidity" + ) + + # The sensor and humidifier should be part of the same device + assert entry.device_id == device.id diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 52685334500..b08659bf77b 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -4,6 +4,7 @@ import unittest.mock from unittest.mock import AsyncMock, patch import aiohomekit +from aiohomekit.exceptions import AuthenticationError from aiohomekit.model import Accessories, Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -351,8 +352,48 @@ async def test_discovery_does_not_ignore_non_homekit(hass, controller): assert result["type"] == "form" +async def test_discovery_broken_pairing_flag(hass, controller): + """ + There is already a config entry for the pairing and its pairing flag is wrong in zeroconf. + + We have seen this particular implementation error in 2 different devices. + """ + await controller.add_paired_device(Accessories(), "00:00:00:00:00:00") + + MockConfigEntry( + domain="homekit_controller", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + unique_id="00:00:00:00:00:00", + ).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Make sure that we are pairable + assert discovery_info["properties"]["sf"] != 0x0 + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + # Should still be paired. + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 1 + + # Even though discovered as pairable, we bail out as already paired. + assert result["reason"] == "already_paired" + + async def test_discovery_invalid_config_entry(hass, controller): """There is already a config entry for the pairing id but it's invalid.""" + pairing = await controller.add_paired_device(Accessories(), "00:00:00:00:00:00") + MockConfigEntry( domain="homekit_controller", data={"AccessoryPairingID": "00:00:00:00:00:00"}, @@ -366,11 +407,16 @@ async def test_discovery_invalid_config_entry(hass, controller): discovery_info = get_device_discovery_info(device) # Device is discovered - result = await hass.config_entries.flow.async_init( - "homekit_controller", - context={"source": config_entries.SOURCE_ZEROCONF}, - data=discovery_info, - ) + with patch.object( + pairing, + "list_accessories_and_characteristics", + side_effect=AuthenticationError("Invalid pairing keys"), + ): + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) # Discovery of a HKID that is in a pairable state but for which there is # already a config entry - in that case the stale config entry is diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index 197b7b3c3b9..15e645bf181 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -57,3 +57,23 @@ async def test_switch_read_lock_state(hass, utcnow): helper.characteristics[LOCK_TARGET_STATE].value = 1 state = await helper.poll_and_get_state() assert state.state == "locked" + + helper.characteristics[LOCK_CURRENT_STATE].value = 2 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "jammed" + + helper.characteristics[LOCK_CURRENT_STATE].value = 3 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "unknown" + + helper.characteristics[LOCK_CURRENT_STATE].value = 0 + helper.characteristics[LOCK_TARGET_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "locking" + + helper.characteristics[LOCK_CURRENT_STATE].value = 1 + helper.characteristics[LOCK_TARGET_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == "unlocking" diff --git a/tests/components/homekit_controller/test_number.py b/tests/components/homekit_controller/test_number.py new file mode 100644 index 00000000000..490b69b1a80 --- /dev/null +++ b/tests/components/homekit_controller/test_number.py @@ -0,0 +1,87 @@ +"""Basic checks for HomeKit sensor.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import Helper, setup_test_component + + +def create_switch_with_spray_level(accessory): + """Define battery level characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + spray_level = service.add_char( + CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL + ) + + spray_level.value = 1 + spray_level.minStep = 1 + spray_level.minValue = 1 + spray_level.maxValue = 5 + spray_level.format = "float" + + cur_state = service.add_char(CharacteristicsTypes.ON) + cur_state.value = True + + return service + + +async def test_read_number(hass, utcnow): + """Test a switch service that has a sensor characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_spray_level) + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "number.testdevice", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + spray_level = outlet[CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL] + + state = await energy_helper.poll_and_get_state() + assert state.state == "1" + assert state.attributes["step"] == 1 + assert state.attributes["min"] == 1 + assert state.attributes["max"] == 5 + + spray_level.value = 5 + state = await energy_helper.poll_and_get_state() + assert state.state == "5" + + +async def test_write_number(hass, utcnow): + """Test a switch service that has a sensor characteristic is correctly handled.""" + helper = await setup_test_component(hass, create_switch_with_spray_level) + outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + + # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. + energy_helper = Helper( + hass, + "number.testdevice", + helper.pairing, + helper.accessory, + helper.config_entry, + ) + + outlet = energy_helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + spray_level = outlet[CharacteristicsTypes.Vendor.VOCOLINC_HUMIDIFIER_SPRAY_LEVEL] + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice", "value": 5}, + blocking=True, + ) + assert spray_level.value == 5 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.testdevice", "value": 3}, + blocking=True, + ) + assert spray_level.value == 3 diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py new file mode 100644 index 00000000000..05e3631e08d --- /dev/null +++ b/tests/components/honeywell/conftest.py @@ -0,0 +1,65 @@ +"""Fixtures for honeywell tests.""" + +from unittest.mock import create_autospec, patch + +import pytest +import somecomfort + +from homeassistant.components.honeywell.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_data(): + """Provide configuration data for tests.""" + return {CONF_USERNAME: "fake", CONF_PASSWORD: "user"} + + +@pytest.fixture +def config_entry(config_data): + """Create a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=config_data, + options={}, + ) + + +@pytest.fixture +def device(): + """Mock a somecomfort.Device.""" + mock_device = create_autospec(somecomfort.Device, instance=True) + mock_device.deviceid.return_value = "device1" + mock_device._data = { + "canControlHumidification": False, + "hasFan": False, + } + mock_device.system_mode = "off" + mock_device.name = "device1" + mock_device.current_temperature = 20 + mock_device.mac_address = "macaddress1" + return mock_device + + +@pytest.fixture +def location(device): + """Mock a somecomfort.Location.""" + mock_location = create_autospec(somecomfort.Location, instance=True) + mock_location.locationid.return_value = "location1" + mock_location.devices_by_id = {device.deviceid: device} + return mock_location + + +@pytest.fixture(autouse=True) +def client(location): + """Mock a somecomfort.SomeComfort client.""" + client_mock = create_autospec(somecomfort.SomeComfort, instance=True) + client_mock.locations_by_id = {location.locationid: location} + + with patch( + "homeassistant.components.honeywell.somecomfort.SomeComfort" + ) as sc_class_mock: + sc_class_mock.return_value = client_mock + yield client_mock diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py deleted file mode 100644 index d97bbc86ed6..00000000000 --- a/tests/components/honeywell/test_climate.py +++ /dev/null @@ -1,430 +0,0 @@ -"""The test the Honeywell thermostat module.""" -import unittest -from unittest import mock - -import pytest -import requests.exceptions -import somecomfort -import voluptuous as vol - -from homeassistant.components.climate.const import ( - ATTR_FAN_MODE, - ATTR_FAN_MODES, - ATTR_HVAC_MODES, -) -import homeassistant.components.honeywell.climate as honeywell -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) - -pytestmark = pytest.mark.skip("Need to be fixed!") - - -class TestHoneywell(unittest.TestCase): - """A test class for Honeywell themostats.""" - - @mock.patch("somecomfort.SomeComfort") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def test_setup_us(self, mock_ht, mock_sc): - """Test for the US setup.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "us", - } - bad_pass_config = {CONF_USERNAME: "user", honeywell.CONF_REGION: "us"} - bad_region_config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "un", - } - - with pytest.raises(vol.Invalid): - honeywell.PLATFORM_SCHEMA(None) - - with pytest.raises(vol.Invalid): - honeywell.PLATFORM_SCHEMA({}) - - with pytest.raises(vol.Invalid): - honeywell.PLATFORM_SCHEMA(bad_pass_config) - - with pytest.raises(vol.Invalid): - honeywell.PLATFORM_SCHEMA(bad_region_config) - - hass = mock.MagicMock() - add_entities = mock.MagicMock() - - locations = [mock.MagicMock(), mock.MagicMock()] - devices_1 = [mock.MagicMock()] - devices_2 = [mock.MagicMock(), mock.MagicMock] - mock_sc.return_value.locations_by_id.values.return_value = locations - locations[0].devices_by_id.values.return_value = devices_1 - locations[1].devices_by_id.values.return_value = devices_2 - - result = honeywell.setup_platform(hass, config, add_entities) - assert result - assert mock_sc.call_count == 1 - assert mock_sc.call_args == mock.call("user", "pass") - mock_ht.assert_has_calls( - [ - mock.call(mock_sc.return_value, devices_1[0], 18, 28, "user", "pass"), - mock.call(mock_sc.return_value, devices_2[0], 18, 28, "user", "pass"), - mock.call(mock_sc.return_value, devices_2[1], 18, 28, "user", "pass"), - ] - ) - - @mock.patch("somecomfort.SomeComfort") - def test_setup_us_failures(self, mock_sc): - """Test the US setup.""" - hass = mock.MagicMock() - add_entities = mock.MagicMock() - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "us", - } - - mock_sc.side_effect = somecomfort.AuthError - result = honeywell.setup_platform(hass, config, add_entities) - assert not result - assert not add_entities.called - - mock_sc.side_effect = somecomfort.SomeComfortError - result = honeywell.setup_platform(hass, config, add_entities) - assert not result - assert not add_entities.called - - @mock.patch("somecomfort.SomeComfort") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None): - """Test for US filtered thermostats.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "us", - "location": loc, - "thermostat": dev, - } - locations = { - 1: mock.MagicMock( - locationid=mock.sentinel.loc1, - devices_by_id={ - 11: mock.MagicMock(deviceid=mock.sentinel.loc1dev1), - 12: mock.MagicMock(deviceid=mock.sentinel.loc1dev2), - }, - ), - 2: mock.MagicMock( - locationid=mock.sentinel.loc2, - devices_by_id={21: mock.MagicMock(deviceid=mock.sentinel.loc2dev1)}, - ), - 3: mock.MagicMock( - locationid=mock.sentinel.loc3, - devices_by_id={31: mock.MagicMock(deviceid=mock.sentinel.loc3dev1)}, - ), - } - mock_sc.return_value = mock.MagicMock(locations_by_id=locations) - hass = mock.MagicMock() - add_entities = mock.MagicMock() - assert honeywell.setup_platform(hass, config, add_entities) is True - - return mock_ht.call_args_list, mock_sc - - def test_us_filtered_thermostat_1(self): - """Test for US filtered thermostats.""" - result, client = self._test_us_filtered_devices(dev=mock.sentinel.loc1dev1) - devices = [x[0][1].deviceid for x in result] - assert [mock.sentinel.loc1dev1] == devices - - def test_us_filtered_thermostat_2(self): - """Test for US filtered location.""" - result, client = self._test_us_filtered_devices(dev=mock.sentinel.loc2dev1) - devices = [x[0][1].deviceid for x in result] - assert [mock.sentinel.loc2dev1] == devices - - def test_us_filtered_location_1(self): - """Test for US filtered locations.""" - result, client = self._test_us_filtered_devices(loc=mock.sentinel.loc1) - devices = [x[0][1].deviceid for x in result] - assert [mock.sentinel.loc1dev1, mock.sentinel.loc1dev2] == devices - - def test_us_filtered_location_2(self): - """Test for US filtered locations.""" - result, client = self._test_us_filtered_devices(loc=mock.sentinel.loc2) - devices = [x[0][1].deviceid for x in result] - assert [mock.sentinel.loc2dev1] == devices - - @mock.patch("evohomeclient.EvohomeClient") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def test_eu_setup_full_config(self, mock_round, mock_evo): - """Test the EU setup with complete configuration.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "eu", - } - mock_evo.return_value.temperatures.return_value = [{"id": "foo"}, {"id": "bar"}] - hass = mock.MagicMock() - add_entities = mock.MagicMock() - assert honeywell.setup_platform(hass, config, add_entities) - assert mock_evo.call_count == 1 - assert mock_evo.call_args == mock.call("user", "pass") - assert mock_evo.return_value.temperatures.call_count == 1 - assert mock_evo.return_value.temperatures.call_args == mock.call( - force_refresh=True - ) - mock_round.assert_has_calls( - [ - mock.call(mock_evo.return_value, "foo", True, 20.0), - mock.call(mock_evo.return_value, "bar", False, 20.0), - ] - ) - assert add_entities.call_count == 2 - - @mock.patch("evohomeclient.EvohomeClient") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def test_eu_setup_partial_config(self, mock_round, mock_evo): - """Test the EU setup with partial configuration.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "eu", - } - - mock_evo.return_value.temperatures.return_value = [{"id": "foo"}, {"id": "bar"}] - - hass = mock.MagicMock() - add_entities = mock.MagicMock() - assert honeywell.setup_platform(hass, config, add_entities) - mock_round.assert_has_calls( - [ - mock.call(mock_evo.return_value, "foo", True, 16), - mock.call(mock_evo.return_value, "bar", False, 16), - ] - ) - - @mock.patch("evohomeclient.EvohomeClient") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def test_eu_setup_bad_temp(self, mock_round, mock_evo): - """Test the EU setup with invalid temperature.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "eu", - } - - with pytest.raises(vol.Invalid): - honeywell.PLATFORM_SCHEMA(config) - - @mock.patch("evohomeclient.EvohomeClient") - @mock.patch("homeassistant.components.honeywell.climate.HoneywellUSThermostat") - def test_eu_setup_error(self, mock_round, mock_evo): - """Test the EU setup with errors.""" - config = { - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - honeywell.CONF_REGION: "eu", - } - mock_evo.return_value.temperatures.side_effect = ( - requests.exceptions.RequestException - ) - add_entities = mock.MagicMock() - hass = mock.MagicMock() - assert not honeywell.setup_platform(hass, config, add_entities) - - -class TestHoneywellRound(unittest.TestCase): - """A test class for Honeywell Round thermostats.""" - - def setup_method(self, method): - """Test the setup method.""" - - def fake_temperatures(force_refresh=None): - """Create fake temperatures.""" - temps = [ - { - "id": "1", - "temp": 20, - "setpoint": 21, - "thermostat": "main", - "name": "House", - }, - { - "id": "2", - "temp": 21, - "setpoint": 22, - "thermostat": "DOMESTIC_HOT_WATER", - }, - ] - return temps - - self.device = mock.MagicMock() - self.device.temperatures.side_effect = fake_temperatures - self.round1 = honeywell.RoundThermostat(self.device, "1", True, 16) - self.round1.update() - self.round2 = honeywell.RoundThermostat(self.device, "2", False, 17) - self.round2.update() - - def test_attributes(self): - """Test the attributes.""" - assert self.round1.name == "House" - assert self.round1.temperature_unit == TEMP_CELSIUS - assert self.round1.current_temperature == 20 - assert self.round1.target_temperature == 21 - assert not self.round1.is_away_mode_on - - assert self.round2.name == "Hot Water" - assert self.round2.temperature_unit == TEMP_CELSIUS - assert self.round2.current_temperature == 21 - assert self.round2.target_temperature is None - assert not self.round2.is_away_mode_on - - def test_away_mode(self): - """Test setting the away mode.""" - assert not self.round1.is_away_mode_on - self.round1.turn_away_mode_on() - assert self.round1.is_away_mode_on - assert self.device.set_temperature.call_count == 1 - assert self.device.set_temperature.call_args == mock.call("House", 16) - - self.device.set_temperature.reset_mock() - self.round1.turn_away_mode_off() - assert not self.round1.is_away_mode_on - assert self.device.cancel_temp_override.call_count == 1 - assert self.device.cancel_temp_override.call_args == mock.call("House") - - def test_set_temperature(self): - """Test setting the temperature.""" - self.round1.set_temperature(temperature=25) - assert self.device.set_temperature.call_count == 1 - assert self.device.set_temperature.call_args == mock.call("House", 25) - - def test_set_hvac_mode(self) -> None: - """Test setting the system operation.""" - self.round1.set_hvac_mode("cool") - assert self.round1.current_operation == "cool" - assert self.device.system_mode == "cool" - - self.round1.set_hvac_mode("heat") - assert self.round1.current_operation == "heat" - assert self.device.system_mode == "heat" - - -class TestHoneywellUS(unittest.TestCase): - """A test class for Honeywell US thermostats.""" - - def setup_method(self, method): - """Test the setup method.""" - self.client = mock.MagicMock() - self.device = mock.MagicMock() - self.cool_away_temp = 18 - self.heat_away_temp = 28 - self.honeywell = honeywell.HoneywellUSThermostat( - self.client, - self.device, - self.cool_away_temp, - self.heat_away_temp, - "user", - "password", - ) - - self.device.fan_running = True - self.device.name = "test" - self.device.temperature_unit = "F" - self.device.current_temperature = 72 - self.device.setpoint_cool = 78 - self.device.setpoint_heat = 65 - self.device.system_mode = "heat" - self.device.fan_mode = "auto" - - def test_properties(self): - """Test the properties.""" - assert self.honeywell.is_fan_on - assert self.honeywell.name == "test" - assert self.honeywell.current_temperature == 72 - - def test_unit_of_measurement(self): - """Test the unit of measurement.""" - assert self.honeywell.temperature_unit == TEMP_FAHRENHEIT - self.device.temperature_unit = "C" - assert self.honeywell.temperature_unit == TEMP_CELSIUS - - def test_target_temp(self): - """Test the target temperature.""" - assert self.honeywell.target_temperature == 65 - self.device.system_mode = "cool" - assert self.honeywell.target_temperature == 78 - - def test_set_temp(self): - """Test setting the temperature.""" - self.honeywell.set_temperature(temperature=70) - assert self.device.setpoint_heat == 70 - assert self.honeywell.target_temperature == 70 - - self.device.system_mode = "cool" - assert self.honeywell.target_temperature == 78 - self.honeywell.set_temperature(temperature=74) - assert self.device.setpoint_cool == 74 - assert self.honeywell.target_temperature == 74 - - def test_set_hvac_mode(self) -> None: - """Test setting the operation mode.""" - self.honeywell.set_hvac_mode("cool") - assert self.device.system_mode == "cool" - - self.honeywell.set_hvac_mode("heat") - assert self.device.system_mode == "heat" - - def test_set_temp_fail(self): - """Test if setting the temperature fails.""" - self.device.setpoint_heat = mock.MagicMock( - side_effect=somecomfort.SomeComfortError - ) - self.honeywell.set_temperature(temperature=123) - - def test_attributes(self): - """Test the attributes.""" - expected = { - honeywell.ATTR_FAN: "running", - ATTR_FAN_MODE: "auto", - ATTR_FAN_MODES: somecomfort.FAN_MODES, - ATTR_HVAC_MODES: somecomfort.SYSTEM_MODES, - } - assert expected == self.honeywell.extra_state_attributes - expected["fan"] = "idle" - self.device.fan_running = False - assert self.honeywell.extra_state_attributes == expected - - def test_with_no_fan(self): - """Test if there is on fan.""" - self.device.fan_running = False - self.device.fan_mode = None - expected = { - honeywell.ATTR_FAN: "idle", - ATTR_FAN_MODE: None, - ATTR_FAN_MODES: somecomfort.FAN_MODES, - ATTR_HVAC_MODES: somecomfort.SYSTEM_MODES, - } - assert self.honeywell.extra_state_attributes == expected - - def test_heat_away_mode(self): - """Test setting the heat away mode.""" - self.honeywell.set_hvac_mode("heat") - assert not self.honeywell.is_away_mode_on - self.honeywell.turn_away_mode_on() - assert self.honeywell.is_away_mode_on - assert self.device.setpoint_heat == self.heat_away_temp - assert self.device.hold_heat is True - - self.honeywell.turn_away_mode_off() - assert not self.honeywell.is_away_mode_on - assert self.device.hold_heat is False - - @mock.patch("somecomfort.SomeComfort") - def test_retry(self, test_somecomfort): - """Test retry connection.""" - old_device = self.honeywell._device - self.honeywell._retry() - assert self.honeywell._device == old_device diff --git a/tests/components/honeywell/test_config_flow.py b/tests/components/honeywell/test_config_flow.py new file mode 100644 index 00000000000..65f47ddf35f --- /dev/null +++ b/tests/components/honeywell/test_config_flow.py @@ -0,0 +1,63 @@ +"""Tests for honeywell config flow.""" +from unittest.mock import patch + +import somecomfort + +from homeassistant import data_entry_flow +from homeassistant.components.honeywell.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant + +FAKE_CONFIG = { + "username": "fake", + "password": "user", + "away_cool_temperature": 88, + "away_heat_temperature": 61, +} + + +async def test_show_authenticate_form(hass: HomeAssistant) -> None: + """Test that the config form is shown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test that an error message is shown on login fail.""" + with patch( + "somecomfort.SomeComfort", + side_effect=somecomfort.AuthError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG + ) + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_create_entry(hass: HomeAssistant) -> None: + """Test that the config entry is created.""" + with patch( + "somecomfort.SomeComfort", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=FAKE_CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == FAKE_CONFIG + + +async def test_async_step_import(hass: HomeAssistant) -> None: + """Test that the import step works.""" + with patch( + "somecomfort.SomeComfort", + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == FAKE_CONFIG diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py new file mode 100644 index 00000000000..d0bdb5ccf2d --- /dev/null +++ b/tests/components/honeywell/test_init.py @@ -0,0 +1,8 @@ +"""Test honeywell setup process.""" + + +async def test_setup_entry(hass, config_entry): + """Initialize the config entry.""" + 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/http/test_ban.py b/tests/components/http/test_ban.py index 717bd9564c0..e3c2abf2293 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -166,7 +166,9 @@ async def test_ip_bans_file_creation(hass, aiohttp_client): resp = await client.get("/") assert resp.status == 401 assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 - m_open.assert_called_once_with(hass.config.path(IP_BANS_FILE), "a") + m_open.assert_called_once_with( + hass.config.path(IP_BANS_FILE), "a", encoding="utf8" + ) resp = await client.get("/") assert resp.status == HTTP_FORBIDDEN diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index baffcef3476..4eb9557c6a7 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -10,7 +10,7 @@ from requests_mock import ANY from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp -from homeassistant.components.huawei_lte.const import DOMAIN +from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -21,6 +21,8 @@ from homeassistant.const import ( from tests.common import MockConfigEntry +FIXTURE_UNIQUE_ID = "SERIALNUMBER" + FIXTURE_USER_INPUT = { CONF_URL: "http://192.168.1.1/", CONF_USERNAME: "admin", @@ -57,20 +59,30 @@ async def test_urlize_plain_host(hass, requests_mock): assert user_input[CONF_URL] == f"http://{host}/" -async def test_already_configured(hass): +async def test_already_configured(hass, requests_mock, login_requests_mock): """Test we reject already configured devices.""" MockConfigEntry( - domain=DOMAIN, data=FIXTURE_USER_INPUT, title="Already configured" + domain=DOMAIN, + unique_id=FIXTURE_UNIQUE_ID, + data=FIXTURE_USER_INPUT, + title="Already configured", ).add_to_hass(hass) + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text="OK", + ) + requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/device/information", + text=f"{FIXTURE_UNIQUE_ID}", + ) + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, - data={ - **FIXTURE_USER_INPUT, - # Tweak URL a bit to check that doesn't fail duplicate detection - CONF_URL: FIXTURE_USER_INPUT[CONF_URL].replace("http", "HTTP"), - }, + data=FIXTURE_USER_INPUT, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -182,7 +194,7 @@ async def test_ssdp(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - assert context[CONF_URL] == url + assert result["data_schema"]({})[CONF_URL] == url async def test_options(hass): @@ -203,3 +215,4 @@ async def test_options(hass): ) assert result["data"][CONF_NAME] == DOMAIN assert result["data"][CONF_RECIPIENT] == [recipient] + assert result["data"][CONF_UNAUTHENTICATED_MODE] is False diff --git a/tests/components/hue/test_device_trigger.py b/tests/components/hue/test_device_trigger.py index 5711c36da98..28bb989d475 100644 --- a/tests/components/hue/test_device_trigger.py +++ b/tests/components/hue/test_device_trigger.py @@ -74,7 +74,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): } expected_triggers = [ trigger_batt, - *[ + *( { "platform": "device", "domain": hue.DOMAIN, @@ -83,7 +83,7 @@ async def test_get_triggers(hass, mock_bridge, device_reg): "subtype": t_subtype, } for t_type, t_subtype in device_trigger.HUE_DIMMER_REMOTE.keys() - ], + ), ] assert_lists_same(triggers, expected_triggers) diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py index bc11c013555..b8e9c83e47d 100644 --- a/tests/components/hue/test_sensor_base.py +++ b/tests/components/hue/test_sensor_base.py @@ -446,6 +446,9 @@ async def test_hue_events(hass, mock_bridge): assert len(hass.states.async_all()) == 7 assert len(events) == 0 + mock_bridge.api.sensors["7"].last_event = {"type": "button"} + mock_bridge.api.sensors["8"].last_event = {"type": "button"} + new_sensor_response = dict(SENSOR_RESPONSE) new_sensor_response["7"]["state"] = { "buttonevent": 18, diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index 829a76f22d3..5d57d3be70d 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -1384,7 +1384,7 @@ async def test_deprecated_effect_names(caplog, hass: HomeAssistant) -> None: {ATTR_ENTITY_ID: TEST_ENTITY_ID_1, ATTR_EFFECT: component}, blocking=True, ) - assert "Use of Hyperion effect '%s' is deprecated" % component in caplog.text + assert f"Use of Hyperion effect '{component}' is deprecated" in caplog.text # Simulate a state callback from Hyperion. client.visible_priority = { diff --git a/tests/components/insteon/mock_connection.py b/tests/components/insteon/mock_connection.py new file mode 100644 index 00000000000..00d2c1ec83a --- /dev/null +++ b/tests/components/insteon/mock_connection.py @@ -0,0 +1,11 @@ +"""Mock connections for Insteon.""" + + +async def mock_successful_connection(*args, **kwargs): + """Return a successful connection.""" + return True + + +async def mock_failed_connection(*args, **kwargs): + """Return a failed connection.""" + raise ConnectionError("Connection failed") diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py index 7ffb0672161..e28e25bf41b 100644 --- a/tests/components/insteon/mock_devices.py +++ b/tests/components/insteon/mock_devices.py @@ -2,11 +2,14 @@ from unittest.mock import AsyncMock, MagicMock from pyinsteon.address import Address +from pyinsteon.constants import ALDBStatus, ResponseStatus from pyinsteon.device_types import ( - GeneralController_MiniRemote_4, + DimmableLightingControl_KeypadLinc_8, + GeneralController, Hub, SwitchedLightingControl_SwitchLinc, ) +from pyinsteon.managers.saved_devices_manager import dict_to_aldb_record class MockSwitchLinc(SwitchedLightingControl_SwitchLinc): @@ -32,7 +35,7 @@ class MockDevices: def __getitem__(self, address): """Return a a device from the device address.""" - return self._devices.get(address) + return self._devices.get(Address(address)) def __iter__(self): """Return an iterator of device addresses.""" @@ -53,13 +56,73 @@ class MockDevices: addr1 = Address("11.11.11") addr2 = Address("22.22.22") addr3 = Address("33.33.33") - self._devices[addr0] = Hub(addr0) - self._devices[addr1] = MockSwitchLinc(addr1, 0x02, 0x00) - self._devices[addr2] = GeneralController_MiniRemote_4(addr2, 0x00, 0x00) - self._devices[addr3] = SwitchedLightingControl_SwitchLinc(addr3, 0x02, 0x00) + self._devices[addr0] = Hub(addr0, 0x03, 0x00, 0x00, "Hub AA.AA.AA", "0") + self._devices[addr1] = MockSwitchLinc( + addr1, 0x02, 0x00, 0x00, "Device 11.11.11", "1" + ) + self._devices[addr2] = GeneralController( + addr2, 0x00, 0x00, 0x00, "Device 22.22.22", "2" + ) + self._devices[addr3] = DimmableLightingControl_KeypadLinc_8( + addr3, 0x02, 0x00, 0x00, "Device 33.33.33", "3" + ) + for device in [self._devices[addr] for addr in [addr1, addr2, addr3]]: device.async_read_config = AsyncMock() + device.aldb.async_write = AsyncMock() + device.aldb.async_load = AsyncMock() + device.async_add_default_links = AsyncMock() + device.async_read_op_flags = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_read_ext_properties = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_write_op_flags = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + device.async_write_ext_properties = AsyncMock( + return_value=ResponseStatus.SUCCESS + ) + for device in [self._devices[addr] for addr in [addr2, addr3]]: device.async_status = AsyncMock() self._devices[addr1].async_status = AsyncMock(side_effect=AttributeError) + self._devices[addr0].aldb.async_load = AsyncMock() + + self._devices[addr2].async_read_op_flags = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_read_ext_properties = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_write_op_flags = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self._devices[addr2].async_write_ext_properties = AsyncMock( + return_value=ResponseStatus.FAILURE + ) + self.modem = self._devices[addr0] + + def fill_aldb(self, address, records): + """Fill the All-Link Database for a device.""" + device = self._devices[Address(address)] + aldb_records = dict_to_aldb_record(records) + + device.aldb.load_saved_records(ALDBStatus.LOADED, aldb_records) + + def fill_properties(self, address, props_dict): + """Fill the operating flags and extended properties of a device.""" + device = self._devices[Address(address)] + operating_flags = props_dict.get("operating_flags", {}) + properties = props_dict.get("properties", {}) + + for flag in operating_flags: + value = operating_flags[flag] + if device.operating_flags.get(flag): + device.operating_flags[flag].load(value) + for flag in properties: + value = properties[flag] + if device.properties.get(flag): + device.properties[flag].load(value) diff --git a/tests/components/insteon/test_api_aldb.py b/tests/components/insteon/test_api_aldb.py new file mode 100644 index 00000000000..d360b34d7b9 --- /dev/null +++ b/tests/components/insteon/test_api_aldb.py @@ -0,0 +1,288 @@ +"""Test the Insteon All-Link Database APIs.""" + +import json +from unittest.mock import patch + +from pyinsteon import pub +from pyinsteon.address import Address +from pyinsteon.topics import ALDB_STATUS_CHANGED, DEVICE_LINK_CONTROLLER_CREATED +import pytest + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.aldb import ( + ALDB_RECORD, + DEVICE_ADDRESS, + ID, + TYPE, +) +from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND + +from .mock_devices import MockDevices + +from tests.common import load_fixture + + +@pytest.fixture(name="aldb_data", scope="session") +def aldb_data_fixture(): + """Load the controller state fixture data.""" + return json.loads(load_fixture("insteon/aldb_data.json")) + + +async def _setup(hass, hass_ws_client, aldb_data): + """Set up tests.""" + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + async_load_api(hass) + devices.fill_aldb("33.33.33", aldb_data) + return ws_client, devices + + +def _compare_records(aldb_rec, dict_rec): + """Compare a record in the ALDB to the dictionary record.""" + assert aldb_rec.is_in_use == dict_rec["in_use"] + assert aldb_rec.is_controller == (dict_rec["is_controller"]) + assert not aldb_rec.is_high_water_mark + assert aldb_rec.group == dict_rec["group"] + assert aldb_rec.target == Address(dict_rec["target"]) + assert aldb_rec.data1 == dict_rec["data1"] + assert aldb_rec.data2 == dict_rec["data2"] + assert aldb_rec.data3 == dict_rec["data3"] + + +def _aldb_dict(mem_addr): + """Generate an ALDB record as a dictionary.""" + return { + "mem_addr": mem_addr, + "in_use": True, + "is_controller": True, + "highwater": False, + "group": 100, + "target": "111111", + "data1": 101, + "data2": 102, + "data3": 103, + "dirty": True, + } + + +async def test_get_aldb(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/aldb/get", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert len(result) == 5 + + +async def test_change_aldb_record(hass, hass_ws_client, aldb_data): + """Test changing an Insteon device's All-Link Database record.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + change_rec = _aldb_dict(4079) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/change", + DEVICE_ADDRESS: "33.33.33", + ALDB_RECORD: change_rec, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(devices["33.33.33"].aldb.pending_changes) == 1 + rec = devices["33.33.33"].aldb.pending_changes[4079] + _compare_records(rec, change_rec) + + +async def test_create_aldb_record(hass, hass_ws_client, aldb_data): + """Test creating a new Insteon All-Link Database record.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + new_rec = _aldb_dict(4079) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/create", + DEVICE_ADDRESS: "33.33.33", + ALDB_RECORD: new_rec, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(devices["33.33.33"].aldb.pending_changes) == 1 + rec = devices["33.33.33"].aldb.pending_changes[-1] + _compare_records(rec, new_rec) + + +async def test_write_aldb(hass, hass_ws_client, aldb_data): + """Test writing an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/write", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].aldb.async_write.call_count == 1 + assert devices["33.33.33"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_load_aldb(hass, hass_ws_client, aldb_data): + """Test loading an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/load", + DEVICE_ADDRESS: "AA.AA.AA", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["AA.AA.AA"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_reset_aldb(hass, hass_ws_client, aldb_data): + """Test resetting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + record = _aldb_dict(4079) + devices["33.33.33"].aldb.modify( + mem_addr=record["mem_addr"], + in_use=record["in_use"], + group=record["group"], + controller=record["is_controller"], + target=record["target"], + data1=record["data1"], + data2=record["data2"], + data3=record["data3"], + ) + + assert devices["33.33.33"].aldb.pending_changes + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/reset", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert not devices["33.33.33"].aldb.pending_changes + + +async def test_default_links(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/add_default_links", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_add_default_links.call_count == 1 + assert devices["33.33.33"].aldb.async_load.call_count == 1 + assert devices.async_save.call_count == 1 + + +async def test_notify_on_aldb_status(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/notify", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + pub.sendMessage(f"333333.{ALDB_STATUS_CHANGED}") + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "status_changed" + assert not msg["event"]["is_loading"] + + +async def test_notify_on_aldb_record_added(hass, hass_ws_client, aldb_data): + """Test getting an Insteon device's All-Link Database.""" + ws_client, devices = await _setup(hass, hass_ws_client, aldb_data) + + with patch.object(insteon.api.aldb, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/aldb/notify", + DEVICE_ADDRESS: "33.33.33", + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + pub.sendMessage( + f"{DEVICE_LINK_CONTROLLER_CREATED}.333333", + controller=Address("11.11.11"), + responder=Address("33.33.33"), + group=100, + ) + msg = await ws_client.receive_json() + assert msg["event"]["type"] == "record_loaded" + + +async def test_bad_address(hass, hass_ws_client, aldb_data): + """Test for a bad Insteon address.""" + ws_client, _ = await _setup(hass, hass_ws_client, aldb_data) + record = _aldb_dict(0) + + ws_id = 0 + for call in ["get", "write", "load", "reset", "add_default_links", "notify"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/aldb/{call}", + DEVICE_ADDRESS: "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + for call in ["change", "create"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/aldb/{call}", + DEVICE_ADDRESS: "99.99.99", + ALDB_RECORD: record, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND diff --git a/tests/components/insteon/test_api_device.py b/tests/components/insteon/test_api_device.py new file mode 100644 index 00000000000..528d44cc691 --- /dev/null +++ b/tests/components/insteon/test_api_device.py @@ -0,0 +1,139 @@ +"""Test the device level APIs.""" +from unittest.mock import patch + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.device import ( + DEVICE_ID, + HA_DEVICE_NOT_FOUND, + ID, + INSTEON_DEVICE_NOT_FOUND, + TYPE, + async_device_name, +) +from homeassistant.components.insteon.const import DOMAIN +from homeassistant.helpers.device_registry import async_get_registry + +from .const import MOCK_USER_INPUT_PLM +from .mock_devices import MockDevices + +from tests.common import MockConfigEntry + + +async def _async_setup(hass, hass_ws_client): + """Set up for tests.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = await async_get_registry(hass) + # Create device registry entry for mock node + ha_device = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "11.11.11")}, + name="Device 11.11.11", + ) + return ws_client, devices, ha_device, dev_reg + + +async def test_get_device_api(hass, hass_ws_client): + """Test getting an Insteon device.""" + + ws_client, devices, ha_device, _ = await _async_setup(hass, hass_ws_client) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device.id} + ) + msg = await ws_client.receive_json() + result = msg["result"] + + assert result["name"] == "Device 11.11.11" + assert result["address"] == "11.11.11" + + +async def test_no_ha_device(hass, hass_ws_client): + """Test response when no HA device exists.""" + + ws_client, devices, _, _ = await _async_setup(hass, hass_ws_client) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: "not_a_device"} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == HA_DEVICE_NOT_FOUND + + +async def test_no_insteon_device(hass, hass_ws_client): + """Test response when no Insteon device exists.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + config_entry.add_to_hass(hass) + async_load_api(hass) + + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + + dev_reg = await async_get_registry(hass) + # Create device registry entry for a Insteon device not in the Insteon devices list + ha_device_1 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "AA.BB.CC")}, + name="HA Device Only", + ) + # Create device registry entry for a non-Insteon device + ha_device_2 = dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("other_domain", "no address")}, + name="HA Device Only", + ) + with patch.object(insteon.api.device, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/device/get", DEVICE_ID: ha_device_1.id} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + await ws_client.send_json( + {ID: 3, TYPE: "insteon/device/get", DEVICE_ID: ha_device_2.id} + ) + msg = await ws_client.receive_json() + assert not msg.get("result") + assert msg.get("error") + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + +async def test_get_ha_device_name(hass, hass_ws_client): + """Test getting the HA device name from an Insteon address.""" + + _, devices, _, device_reg = await _async_setup(hass, hass_ws_client) + + with patch.object(insteon.api.device, "devices", devices): + # Test a real HA and Insteon device + name = await async_device_name(device_reg, "11.11.11") + assert name == "Device 11.11.11" + + # Test no HA device but a real Insteon device + name = await async_device_name(device_reg, "22.22.22") + assert name == "Device 22.22.22 (2)" + + # Test no HA or Insteon device + name = await async_device_name(device_reg, "BB.BB.BB") + assert name == "" diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py new file mode 100644 index 00000000000..9b628f4443a --- /dev/null +++ b/tests/components/insteon/test_api_properties.py @@ -0,0 +1,425 @@ +"""Test the Insteon properties APIs.""" + +import json +from unittest.mock import patch + +import pytest + +from homeassistant.components import insteon +from homeassistant.components.insteon.api import async_load_api +from homeassistant.components.insteon.api.device import INSTEON_DEVICE_NOT_FOUND +from homeassistant.components.insteon.api.properties import ( + DEVICE_ADDRESS, + ID, + NON_TOGGLE_MASK, + NON_TOGGLE_OFF_MODE, + NON_TOGGLE_ON_MODE, + NON_TOGGLE_ON_OFF_MASK, + PROPERTY_NAME, + PROPERTY_VALUE, + RADIO_BUTTON_GROUP_PROP, + TOGGLE_MODES, + TOGGLE_ON_OFF_MODE, + TOGGLE_PROP, + TYPE, + _get_radio_button_properties, + _get_toggle_properties, +) + +from .mock_devices import MockDevices + +from tests.common import load_fixture + + +@pytest.fixture(name="properties_data", scope="session") +def aldb_data_fixture(): + """Load the controller state fixture data.""" + return json.loads(load_fixture("insteon/kpl_properties.json")) + + +async def _setup(hass, hass_ws_client, properties_data): + """Set up tests.""" + ws_client = await hass_ws_client(hass) + devices = MockDevices() + await devices.async_load() + devices.fill_properties("33.33.33", properties_data) + async_load_api(hass) + return ws_client, devices + + +async def test_get_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/get", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert len(msg["result"]["properties"]) == 54 + + +async def test_change_operating_flag(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "led_off", + PROPERTY_VALUE: True, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].operating_flags["led_off"].is_dirty + + +async def test_change_property(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "on_mask", + PROPERTY_VALUE: 100, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].properties["on_mask"].new_value == 100 + assert devices["33.33.33"].properties["on_mask"].is_dirty + + +async def test_change_ramp_rate_property(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: "ramp_rate", + PROPERTY_VALUE: 4.5, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].properties["ramp_rate"].new_value == 0x1A + assert devices["33.33.33"].properties["ramp_rate"].is_dirty + + +async def test_change_radio_button_group(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + rb_props, schema = _get_radio_button_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert rb_props[0]["name"] == f"{RADIO_BUTTON_GROUP_PROP}0" + assert rb_props[0]["value"] == [4, 5] + assert rb_props[1]["value"] == [7, 8] + assert rb_props[2]["value"] == [] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + assert devices["33.33.33"].properties["on_mask"].value == 0 + assert devices["33.33.33"].properties["off_mask"].value == 0 + assert not devices["33.33.33"].properties["on_mask"].is_dirty + assert not devices["33.33.33"].properties["off_mask"].is_dirty + + # Add button 1 to the group + rb_props[0]["value"].append(1) + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}0", + PROPERTY_VALUE: rb_props[0]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert 1 in new_rb_props[0]["value"] + assert 4 in new_rb_props[0]["value"] + assert 5 in new_rb_props[0]["value"] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 0x18 + assert devices["33.33.33"].properties["off_mask"].new_value == 0x18 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + # Remove button 5 + rb_props[0]["value"].remove(5) + await ws_client.send_json( + { + ID: 3, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}0", + PROPERTY_VALUE: rb_props[0]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert 1 in new_rb_props[0]["value"] + assert 4 in new_rb_props[0]["value"] + assert 5 not in new_rb_props[0]["value"] + assert schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 0x08 + assert devices["33.33.33"].properties["off_mask"].new_value == 0x08 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + # Remove button group 1 + rb_props[1]["value"] = [] + await ws_client.send_json( + { + ID: 5, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}1", + PROPERTY_VALUE: rb_props[1]["value"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + assert len(new_rb_props) == 2 + assert new_rb_props[0]["value"] == [1, 4] + assert new_rb_props[1]["value"] == [] + + +async def test_create_radio_button_group(hass, hass_ws_client, properties_data): + """Test changing an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + rb_props, _ = _get_radio_button_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert len(rb_props) == 3 + print(rb_props) + + rb_props[0]["value"].append("1") + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: f"{RADIO_BUTTON_GROUP_PROP}2", + PROPERTY_VALUE: ["1", "3"], + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_rb_props, new_schema = _get_radio_button_properties(devices["33.33.33"]) + assert len(new_rb_props) == 4 + assert 1 in new_rb_props[0]["value"] + assert new_schema[f"{RADIO_BUTTON_GROUP_PROP}0"]["options"].get(1) + assert not new_schema[f"{RADIO_BUTTON_GROUP_PROP}1"]["options"].get(1) + + assert devices["33.33.33"].properties["on_mask"].new_value == 4 + assert devices["33.33.33"].properties["off_mask"].new_value == 4 + assert devices["33.33.33"].properties["on_mask"].is_dirty + assert devices["33.33.33"].properties["off_mask"].is_dirty + + +async def test_change_toggle_property(hass, hass_ws_client, properties_data): + """Update a button's toggle mode.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + device = devices["33.33.33"] + toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + + # Make sure the baseline is correct + assert toggle_props[0]["name"] == f"{TOGGLE_PROP}{device.groups[1].name}" + assert toggle_props[0]["value"] == TOGGLE_MODES[TOGGLE_ON_OFF_MODE] + assert toggle_props[1]["value"] == TOGGLE_MODES[NON_TOGGLE_ON_MODE] + assert device.properties[NON_TOGGLE_MASK].value == 2 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].value == 2 + assert not device.properties[NON_TOGGLE_MASK].is_dirty + assert not device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + { + ID: 2, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[0]["name"], + PROPERTY_VALUE: 1, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[0]["value"] == TOGGLE_MODES[NON_TOGGLE_ON_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + await ws_client.send_json( + { + ID: 3, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[0]["name"], + PROPERTY_VALUE: 2, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[0]["value"] == TOGGLE_MODES[NON_TOGGLE_OFF_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 3 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value is None + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert not device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + await ws_client.send_json( + { + ID: 4, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "33.33.33", + PROPERTY_NAME: toggle_props[1]["name"], + PROPERTY_VALUE: 0, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + new_toggle_props, _ = _get_toggle_properties(devices["33.33.33"]) + assert new_toggle_props[1]["value"] == TOGGLE_MODES[TOGGLE_ON_OFF_MODE] + assert device.properties[NON_TOGGLE_MASK].new_value == 1 + assert device.properties[NON_TOGGLE_ON_OFF_MASK].new_value == 0 + assert device.properties[NON_TOGGLE_MASK].is_dirty + assert device.properties[NON_TOGGLE_ON_OFF_MASK].is_dirty + + +async def test_write_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/write", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_write_op_flags.call_count == 1 + assert devices["33.33.33"].async_write_ext_properties.call_count == 1 + + +async def test_write_properties_failure(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/write", DEVICE_ADDRESS: "22.22.22"} + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "write_failed" + + +async def test_load_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert devices["33.33.33"].async_read_op_flags.call_count == 1 + assert devices["33.33.33"].async_read_ext_properties.call_count == 1 + + +async def test_load_properties_failure(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "22.22.22"} + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "load_failed" + + +async def test_reset_properties(hass, hass_ws_client, properties_data): + """Test getting an Insteon device's properties.""" + ws_client, devices = await _setup(hass, hass_ws_client, properties_data) + + device = devices["33.33.33"] + device.operating_flags["led_off"].new_value = True + device.properties["on_mask"].new_value = 100 + assert device.operating_flags["led_off"].is_dirty + assert device.properties["on_mask"].is_dirty + with patch.object(insteon.api.properties, "devices", devices): + await ws_client.send_json( + {ID: 2, TYPE: "insteon/properties/reset", DEVICE_ADDRESS: "33.33.33"} + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert not device.operating_flags["led_off"].is_dirty + assert not device.properties["on_mask"].is_dirty + + +async def test_bad_address(hass, hass_ws_client, properties_data): + """Test for a bad Insteon address.""" + ws_client, _ = await _setup(hass, hass_ws_client, properties_data) + + ws_id = 0 + for call in ["get", "write", "load", "reset"]: + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: f"insteon/properties/{call}", + DEVICE_ADDRESS: "99.99.99", + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND + + ws_id += 1 + await ws_client.send_json( + { + ID: ws_id, + TYPE: "insteon/properties/change", + DEVICE_ADDRESS: "99.99.99", + PROPERTY_NAME: "led_off", + PROPERTY_VALUE: True, + } + ) + msg = await ws_client.receive_json() + assert not msg["success"] + assert msg["error"]["message"] == INSTEON_DEVICE_NOT_FOUND diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 3afa5c14c22..dd6bf980d0f 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,12 +2,22 @@ from datetime import timedelta from unittest.mock import patch -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, TIME_SECONDS +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT +from homeassistant.const import ( + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + TIME_SECONDS, +) +from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from tests.common import mock_restore_cache -async def test_state(hass): + +async def test_state(hass) -> None: """Test integration sensor state.""" config = { "sensor": { @@ -19,15 +29,25 @@ async def test_state(hass): } } - assert await async_setup_component(hass, "sensor", config) - - entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - now = dt_util.utcnow() + timedelta(seconds=3600) + now = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=now): - hass.states.async_set(entity_id, 1, {}, force_update=True) + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, 1, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + assert state.attributes.get("last_reset") == now.isoformat() + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert "device_class" not in state.attributes + + future_now = dt_util.utcnow() + timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=future_now): + hass.states.async_set( + entity_id, 1, {"device_class": DEVICE_CLASS_POWER}, force_update=True + ) await hass.async_block_till_done() state = hass.states.get("sensor.integration") @@ -37,6 +57,82 @@ async def test_state(hass): assert round(float(state.state), config["sensor"]["round"]) == 1.0 assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("last_reset") == now.isoformat() + + +async def test_restore_state(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache( + hass, + ( + State( + "sensor.integration", + "100.0", + { + "last_reset": "2019-10-06T21:00:00", + "device_class": DEVICE_CLASS_ENERGY, + }, + ), + ), + ) + + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "unit": ENERGY_KILO_WATT_HOUR, + "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") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("device_class") == DEVICE_CLASS_ENERGY + assert state.attributes.get("last_reset") == "2019-10-06T21:00:00" + + +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", + "unit": ENERGY_KILO_WATT_HOUR, + } + } + + 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 == "0" + assert state.attributes.get("unit_of_measurement") == ENERGY_KILO_WATT_HOUR + assert state.attributes.get("state_class") == STATE_CLASS_MEASUREMENT + assert state.attributes.get("last_reset") != "2019-10-06T21:00:00" + assert "device_class" not in state.attributes async def test_trapezoidal(hass): diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py index e5458a3c96b..1e96de9ff2f 100644 --- a/tests/components/isy994/test_config_flow.py +++ b/tests/components/isy994/test_config_flow.py @@ -156,6 +156,24 @@ async def test_form_invalid_auth(hass: HomeAssistant): assert result2["errors"] == {"base": "invalid_auth"} +async def test_form_unknown_exeption(hass: HomeAssistant): + """Test we handle generic exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + PATCH_CONNECTION, + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + async def test_form_isy_connection_error(hass: HomeAssistant): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( @@ -355,6 +373,146 @@ async def test_form_ssdp(hass: HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_ssdp_existing_entry(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:80{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_with_no_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with no port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:80/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_with_alternate_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with an alternate port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}:1443/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://3.3.3.3:1443/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_ssdp_existing_entry_no_port_https(hass: HomeAssistant): + """Test we update the ip of an existing entry from ssdp with no port and https.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"https://{MOCK_HOSTNAME}/{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: f"https://3.3.3.3/{ISY_URL_POSTFIX}", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "myisy", + ssdp.ATTR_UPNP_UDN: f"{UDN_UUID_PREFIX}{MOCK_UUID}", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"https://3.3.3.3:443/{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_dhcp(hass: HomeAssistant): """Test we can setup from dhcp.""" await setup.async_setup_component(hass, "persistent_notification", {}) @@ -390,3 +548,77 @@ async def test_form_dhcp(hass: HomeAssistant): assert result2["data"] == MOCK_USER_INPUT assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_dhcp_existing_entry(hass: HomeAssistant): + """Test we update the ip of an existing entry from dhcp.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: f"http://{MOCK_HOSTNAME}{ISY_URL_POSTFIX}"}, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data={ + dhcp.IP_ADDRESS: "1.2.3.4", + dhcp.HOSTNAME: "isy994-ems", + dhcp.MAC_ADDRESS: MOCK_MAC, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://1.2.3.4{ISY_URL_POSTFIX}" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_dhcp_existing_entry_preserves_port(hass: HomeAssistant): + """Test we update the ip of an existing entry from dhcp preserves port.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "bob", + CONF_HOST: f"http://{MOCK_HOSTNAME}:1443{ISY_URL_POSTFIX}", + }, + unique_id=MOCK_UUID, + ) + entry.add_to_hass(hass) + + with patch(PATCH_CONNECTION, return_value=MOCK_CONFIG_RESPONSE), patch( + PATCH_ASYNC_SETUP, return_value=True + ) as mock_setup, patch( + PATCH_ASYNC_SETUP_ENTRY, + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data={ + dhcp.IP_ADDRESS: "1.2.3.4", + dhcp.HOSTNAME: "isy994-ems", + dhcp.MAC_ADDRESS: MOCK_MAC, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data[CONF_HOST] == f"http://1.2.3.4:1443{ISY_URL_POSTFIX}" + assert entry.data[CONF_USERNAME] == "bob" + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 970e31c7985..e9471d5d144 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -188,13 +188,13 @@ async def test_jewish_calendar_sensor( await hass.async_block_till_done() result = ( - dt_util.as_utc(result.replace(tzinfo=time_zone)) + dt_util.as_utc(result.replace(tzinfo=time_zone)).isoformat() if isinstance(result, dt) else result ) sensor_object = hass.states.get(f"sensor.test_{sensor}") - assert sensor_object.state == str(result) + assert sensor_object.state == result if sensor == "holiday": assert sensor_object.attributes.get("id") == "rosh_hashana_i" @@ -544,7 +544,7 @@ async def test_shabbat_times_sensor( sensor_type = sensor_type.replace(f"{language}_", "") result_value = ( - dt_util.as_utc(result_value) + dt_util.as_utc(result_value).isoformat() if isinstance(result_value, dt) else result_value ) diff --git a/tests/components/knx/README.md b/tests/components/knx/README.md new file mode 100644 index 00000000000..4b5886200c4 --- /dev/null +++ b/tests/components/knx/README.md @@ -0,0 +1,71 @@ +# Testing the KNX integration + +A KNXTestKit instance can be requested from a fixture. It provides convenience methods +to test outgoing KNX telegrams and inject incoming telegrams. +To test something add a test function requesting the `hass` and `knx` fixture and +set up the KNX integration by passing a KNX config dict to `knx.setup_integration`. + +```python +async def test_something(hass, knx): + await knx.setup_integration({ + "switch": { + "name": "test_switch", + "address": "1/2/3", + } + } + ) +``` + +## Asserting outgoing telegrams + +All outgoing telegrams are pushed to an assertion queue. Assert them in order they were sent. + +- `knx.assert_no_telegram` + Asserts that no telegram was sent (assertion queue is empty). +- `knx.assert_telegram_count(count: int)` + Asserts that `count` telegrams were sent. +- `knx.assert_read(group_address: str)` + Asserts that a GroupValueRead telegram was sent to `group_address`. + The telegram will be removed from the assertion queue. +- `knx.assert_response(group_address: str, payload: int | tuple[int, ...])` + Asserts that a GroupValueResponse telegram with `payload` was sent to `group_address`. + The telegram will be removed from the assertion queue. +- `knx.assert_write(group_address: str, payload: int | tuple[int, ...])` + Asserts that a GroupValueWrite telegram with `payload` was sent to `group_address`. + The telegram will be removed from the assertion queue. + +Change some states or call some services and assert outgoing telegrams. + +```python + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test_switch"}, blocking=True + ) + # assert ON telegram + await knx.assert_write("1/2/3", True) +``` + +## Injecting incoming telegrams + +- `knx.receive_read(group_address: str)` + Inject and process a GroupValueRead telegram addressed to `group_address`. +- `knx.receive_response(group_address: str, payload: int | tuple[int, ...])` + Inject and process a GroupValueResponse telegram addressed to `group_address` containing `payload`. +- `knx.receive_write(group_address: str, payload: int | tuple[int, ...])` + Inject and process a GroupValueWrite telegram addressed to `group_address` containing `payload`. + +Receive some telegrams and assert state. + +```python + # receive OFF telegram + await knx.receive_write("1/2/3", False) + # assert OFF state + state = hass.states.get("switch.test_switch") + assert state.state is STATE_OFF +``` + +## Notes + +- For `payload` in `assert_*` and `receive_*` use `int` for DPT 1, 2 and 3 payload values (DPTBinary) and `tuple` for other DPTs (DPTArray). +- `await self.hass.async_block_till_done()` is called before `KNXTestKit.assert_*` and after `KNXTestKit.receive_*` so you don't have to explicitly call it. +- Make sure to assert every outgoing telegram that was created in a test. `assert_no_telegram` is automatically called on teardown. diff --git a/tests/components/knx/__init__.py b/tests/components/knx/__init__.py index 1c9bfaf15b8..eaa84714dc5 100644 --- a/tests/components/knx/__init__.py +++ b/tests/components/knx/__init__.py @@ -1,27 +1 @@ """Tests for the KNX integration.""" - -from unittest.mock import DEFAULT, patch - -from homeassistant.components.knx.const import DOMAIN as KNX_DOMAIN -from homeassistant.setup import async_setup_component - - -async def setup_knx_integration(hass, knx_ip_interface, config=None): - """Create the KNX gateway.""" - if config is None: - config = {} - - # To get the XKNX object from the constructor call - def side_effect(*args, **kwargs): - knx_ip_interface.xknx = args[0] - # switch off rate delimiter - knx_ip_interface.xknx.rate_limit = 0 - return DEFAULT - - with patch( - "xknx.xknx.KNXIPInterface", - return_value=knx_ip_interface, - side_effect=side_effect, - ): - await async_setup_component(hass, KNX_DOMAIN, {KNX_DOMAIN: config}) - await hass.async_block_till_done() diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index b7c27774f78..0dc8749830e 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -1,15 +1,185 @@ -"""conftest for knx.""" +"""Conftest for the KNX integration.""" +from __future__ import annotations -from unittest.mock import AsyncMock, Mock +import asyncio +from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest +from xknx import XKNX +from xknx.dpt import DPTArray, DPTBinary +from xknx.telegram import Telegram, TelegramDirection +from xknx.telegram.address import GroupAddress, IndividualAddress +from xknx.telegram.apci import APCI, GroupValueRead, GroupValueResponse, GroupValueWrite + +from homeassistant.components.knx.const import DOMAIN as KNX_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component -@pytest.fixture(autouse=True) -def knx_ip_interface_mock(): - """Create a knx ip interface mock.""" - mock = Mock() - mock.start = AsyncMock() - mock.stop = AsyncMock() - mock.send_telegram = AsyncMock() - return mock +class KNXTestKit: + """Test helper for the KNX integration.""" + + INDIVIDUAL_ADDRESS = "1.2.3" + + def __init__(self, hass: HomeAssistant): + """Init KNX test helper class.""" + self.hass: HomeAssistant = hass + self.xknx: XKNX + # outgoing telegrams will be put in the Queue instead of sent to the interface + # telegrams to an InternalGroupAddress won't be queued here + self._outgoing_telegrams: asyncio.Queue = asyncio.Queue() + + async def setup_integration(self, config): + """Create the KNX integration.""" + + def knx_ip_interface_mock(): + """Create a xknx knx ip interface mock.""" + mock = Mock() + mock.start = AsyncMock() + mock.stop = AsyncMock() + mock.send_telegram = AsyncMock(side_effect=self._outgoing_telegrams.put) + return mock + + def fish_xknx(*args, **kwargs): + """Get the XKNX object from the constructor call.""" + self.xknx = args[0] + # disable rate limiter for tests (before StateUpdater starts) + self.xknx.rate_limit = 0 + return DEFAULT + + with patch( + "xknx.xknx.KNXIPInterface", + return_value=knx_ip_interface_mock(), + side_effect=fish_xknx, + ): + await async_setup_component(self.hass, KNX_DOMAIN, {KNX_DOMAIN: config}) + await self.hass.async_block_till_done() + + ######################## + # Telegram counter tests + ######################## + + def _list_remaining_telegrams(self) -> str: + """Return a string containing remaining outgoing telegrams in test Queue. One per line.""" + remaining_telegrams = [] + while not self._outgoing_telegrams.empty(): + remaining_telegrams.append(self._outgoing_telegrams.get_nowait()) + return "\n".join(map(str, remaining_telegrams)) + + async def assert_no_telegram(self) -> None: + """Assert if every telegram in test Queue was checked.""" + await self.hass.async_block_till_done() + assert self._outgoing_telegrams.empty(), ( + f"Found remaining unasserted Telegrams: {self._outgoing_telegrams.qsize()}\n" + f"{self._list_remaining_telegrams()}" + ) + + async def assert_telegram_count(self, count: int) -> None: + """Assert outgoing telegram count in test Queue.""" + await self.hass.async_block_till_done() + actual_count = self._outgoing_telegrams.qsize() + assert actual_count == count, ( + f"Outgoing telegrams: {actual_count} - Expected: {count}\n" + f"{self._list_remaining_telegrams()}" + ) + + #################### + # APCI Service tests + #################### + + async def _assert_telegram( + self, + group_address: str, + payload: int | tuple[int, ...] | None, + apci_type: type[APCI], + ) -> None: + """Assert outgoing telegram. One by one in timely order.""" + await self.hass.async_block_till_done() + try: + telegram = self._outgoing_telegrams.get_nowait() + except asyncio.QueueEmpty: + raise AssertionError( + f"No Telegram found. Expected: {apci_type.__name__} -" + f" {group_address} - {payload}" + ) + + assert isinstance( + telegram.payload, apci_type + ), f"APCI type mismatch in {telegram} - Expected: {apci_type.__name__}" + + assert ( + str(telegram.destination_address) == group_address + ), f"Group address mismatch in {telegram} - Expected: {group_address}" + + if payload is not None: + assert ( + telegram.payload.value.value == payload # type: ignore + ), f"Payload mismatch in {telegram} - Expected: {payload}" + + async def assert_read(self, group_address: str) -> None: + """Assert outgoing GroupValueRead telegram. One by one in timely order.""" + await self._assert_telegram(group_address, None, GroupValueRead) + + async def assert_response( + self, group_address: str, payload: int | tuple[int, ...] + ) -> None: + """Assert outgoing GroupValueResponse telegram. One by one in timely order.""" + await self._assert_telegram(group_address, payload, GroupValueResponse) + + async def assert_write( + self, group_address: str, payload: int | tuple[int, ...] + ) -> None: + """Assert outgoing GroupValueWrite telegram. One by one in timely order.""" + await self._assert_telegram(group_address, payload, GroupValueWrite) + + #################### + # Incoming telegrams + #################### + + @staticmethod + def _payload_value(payload: int | tuple[int, ...]) -> DPTArray | DPTBinary: + """Prepare payload value for GroupValueWrite or GroupValueResponse.""" + if isinstance(payload, int): + return DPTBinary(payload) + return DPTArray(payload) + + async def _receive_telegram(self, group_address: str, payload: APCI) -> None: + """Inject incoming KNX telegram.""" + self.xknx.telegrams.put_nowait( + Telegram( + destination_address=GroupAddress(group_address), + direction=TelegramDirection.INCOMING, + payload=payload, + source_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), + ) + ) + await self.hass.async_block_till_done() + + async def receive_read( + self, + group_address: str, + ) -> None: + """Inject incoming GroupValueRead telegram.""" + await self._receive_telegram(group_address, GroupValueRead()) + + async def receive_response( + self, group_address: str, payload: int | tuple[int, ...] + ) -> None: + """Inject incoming GroupValueResponse telegram.""" + payload_value = self._payload_value(payload) + await self._receive_telegram(group_address, GroupValueResponse(payload_value)) + + async def receive_write( + self, group_address: str, payload: int | tuple[int, ...] + ) -> None: + """Inject incoming GroupValueWrite telegram.""" + payload_value = self._payload_value(payload) + await self._receive_telegram(group_address, GroupValueWrite(payload_value)) + + +@pytest.fixture +async def knx(request, hass): + """Create a KNX TestKit instance.""" + knx_test_kit = KNXTestKit(hass) + yield knx_test_kit + await knx_test_kit.assert_no_telegram() diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py new file mode 100644 index 00000000000..6a9e021ff53 --- /dev/null +++ b/tests/components/knx/test_events.py @@ -0,0 +1,83 @@ +"""Test KNX events.""" + +from homeassistant.components.knx import CONF_KNX_EVENT_FILTER +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_knx_event(hass: HomeAssistant, knx: KNXTestKit): + """Test `knx_event` event.""" + test_group_a = "0/4/*" + test_address_a_1 = "0/4/0" + test_address_a_2 = "0/4/100" + test_group_b = "1/3-6/*" + test_address_b_1 = "1/3/0" + test_address_b_2 = "1/6/200" + test_group_c = "2/6/4,5" + test_address_c_1 = "2/6/4" + test_address_c_2 = "2/6/5" + test_address_d = "5/4/3" + events = async_capture_events(hass, "knx_event") + + async def test_event_data(address, payload): + await hass.async_block_till_done() + assert len(events) == 1 + event = events.pop() + assert event.data["data"] == payload + assert event.data["direction"] == "Incoming" + assert event.data["destination"] == address + if payload is None: + assert event.data["telegramtype"] == "GroupValueRead" + else: + assert event.data["telegramtype"] in ( + "GroupValueWrite", + "GroupValueResponse", + ) + assert event.data["source"] == KNXTestKit.INDIVIDUAL_ADDRESS + + await knx.setup_integration( + { + CONF_KNX_EVENT_FILTER: [ + test_group_a, + test_group_b, + test_group_c, + test_address_d, + ] + } + ) + + # no event received + await hass.async_block_till_done() + assert len(events) == 0 + + # receive telegrams for group addresses matching the filter + await knx.receive_write(test_address_a_1, True) + await test_event_data(test_address_a_1, True) + + await knx.receive_response(test_address_a_2, False) + await test_event_data(test_address_a_2, False) + + await knx.receive_write(test_address_b_1, (1,)) + await test_event_data(test_address_b_1, (1,)) + + await knx.receive_response(test_address_b_2, (255,)) + await test_event_data(test_address_b_2, (255,)) + + await knx.receive_write(test_address_c_1, (89, 43, 34, 11)) + await test_event_data(test_address_c_1, (89, 43, 34, 11)) + + await knx.receive_response(test_address_c_2, (255, 255, 255, 255)) + await test_event_data(test_address_c_2, (255, 255, 255, 255)) + + await knx.receive_read(test_address_d) + await test_event_data(test_address_d, None) + + # receive telegrams for group addresses not matching the filter + await knx.receive_write("0/5/0", True) + await knx.receive_write("1/7/0", True) + await knx.receive_write("2/6/6", True) + await hass.async_block_till_done() + assert len(events) == 0 diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index 908ef0a56f8..25ec0f92604 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -1,18 +1,16 @@ -"""Test knx expose.""" - - +"""Test KNX expose.""" from homeassistant.components.knx import CONF_KNX_EXPOSE, KNX_ADDRESS +from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE +from homeassistant.core import HomeAssistant -from . import setup_knx_integration +from .conftest import KNXTestKit -async def test_binary_expose(hass, knx_ip_interface_mock): - """Test that a binary expose sends only telegrams on state change.""" +async def test_binary_expose(hass: HomeAssistant, knx: KNXTestKit): + """Test a binary expose to only send telegrams on state change.""" entity_id = "fake.entity" - await setup_knx_integration( - hass, - knx_ip_interface_mock, + await knx.setup_integration( { CONF_KNX_EXPOSE: { CONF_TYPE: "binary", @@ -24,37 +22,23 @@ async def test_binary_expose(hass, knx_ip_interface_mock): assert not hass.states.async_all() # Change state to on - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "on", {}) - await hass.async_block_till_done() - assert ( - knx_ip_interface_mock.send_telegram.call_count == 1 - ), "Expected telegram for state change" + await knx.assert_write("1/1/8", True) # Change attribute; keep state - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "on", {"brightness": 180}) - await hass.async_block_till_done() - assert ( - knx_ip_interface_mock.send_telegram.call_count == 0 - ), "Expected no telegram; state not changed" + await knx.assert_no_telegram() # Change attribute and state - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "off", {"brightness": 0}) - await hass.async_block_till_done() - assert ( - knx_ip_interface_mock.send_telegram.call_count == 1 - ), "Expected telegram for state change" + await knx.assert_write("1/1/8", False) -async def test_expose_attribute(hass, knx_ip_interface_mock): - """Test that an expose sends only telegrams on attribute change.""" +async def test_expose_attribute(hass: HomeAssistant, knx: KNXTestKit): + """Test an expose to only send telegrams on attribute change.""" entity_id = "fake.entity" attribute = "fake_attribute" - await setup_knx_integration( - hass, - knx_ip_interface_mock, + await knx.setup_integration( { CONF_KNX_EXPOSE: { CONF_TYPE: "percentU8", @@ -66,26 +50,76 @@ async def test_expose_attribute(hass, knx_ip_interface_mock): ) assert not hass.states.async_all() - # Change state to on; no attribute - knx_ip_interface_mock.reset_mock() + # Before init no response shall be sent + await knx.receive_read("1/1/8") + await knx.assert_telegram_count(0) + + # Change state to "on"; no attribute hass.states.async_set(entity_id, "on", {}) - await hass.async_block_till_done() - assert knx_ip_interface_mock.send_telegram.call_count == 0 + await knx.assert_telegram_count(0) # Change attribute; keep state - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "on", {attribute: 1}) - await hass.async_block_till_done() - assert knx_ip_interface_mock.send_telegram.call_count == 1 + await knx.assert_write("1/1/8", (1,)) + + # Read in between + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", (1,)) # Change state keep attribute - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "off", {attribute: 1}) - await hass.async_block_till_done() - assert knx_ip_interface_mock.send_telegram.call_count == 0 + await knx.assert_telegram_count(0) # Change state and attribute - knx_ip_interface_mock.reset_mock() hass.states.async_set(entity_id, "on", {attribute: 0}) - await hass.async_block_till_done() - assert knx_ip_interface_mock.send_telegram.call_count == 1 + await knx.assert_write("1/1/8", (0,)) + + # Change state to "off"; no attribute + hass.states.async_set(entity_id, "off", {}) + await knx.assert_telegram_count(0) + + +async def test_expose_attribute_with_default(hass: HomeAssistant, knx: KNXTestKit): + """Test an expose to only send telegrams on attribute change.""" + entity_id = "fake.entity" + attribute = "fake_attribute" + await knx.setup_integration( + { + CONF_KNX_EXPOSE: { + CONF_TYPE: "percentU8", + KNX_ADDRESS: "1/1/8", + CONF_ENTITY_ID: entity_id, + CONF_ATTRIBUTE: attribute, + ExposeSchema.CONF_KNX_EXPOSE_DEFAULT: 0, + } + }, + ) + assert not hass.states.async_all() + + # Before init default value shall be sent as response + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", (0,)) + + # Change state to "on"; no attribute + hass.states.async_set(entity_id, "on", {}) + await knx.assert_write("1/1/8", (0,)) + + # Change attribute; keep state + hass.states.async_set(entity_id, "on", {attribute: 1}) + await knx.assert_write("1/1/8", (1,)) + + # Change state keep attribute + hass.states.async_set(entity_id, "off", {attribute: 1}) + await knx.assert_no_telegram() + + # Change state and attribute + hass.states.async_set(entity_id, "on", {attribute: 3}) + await knx.assert_write("1/1/8", (3,)) + + # Read in between + await knx.receive_read("1/1/8") + await knx.assert_response("1/1/8", (3,)) + + # Change state to "off"; no attribute + hass.states.async_set(entity_id, "off", {}) + await knx.assert_write("1/1/8", (0,)) diff --git a/tests/components/knx/test_select.py b/tests/components/knx/test_select.py new file mode 100644 index 00000000000..d8089976aca --- /dev/null +++ b/tests/components/knx/test_select.py @@ -0,0 +1,187 @@ +"""Test KNX select.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + KNX_ADDRESS, +) +from homeassistant.components.knx.schema import SelectSchema +from homeassistant.const import CONF_NAME, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State + +from .conftest import KNXTestKit + + +async def test_select_dpt_2_simple(hass: HomeAssistant, knx: KNXTestKit): + """Test simple KNX select.""" + _options = [ + {SelectSchema.CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {SelectSchema.CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {SelectSchema.CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + await knx.setup_integration( + { + SelectSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + CONF_SYNC_STATE: False, + SelectSchema.CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + assert len(hass.states.async_all()) == 1 + state = hass.states.get("select.test") + assert state.state is STATE_UNKNOWN + + # select an option + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test", "option": "Control - Off"}, + blocking=True, + ) + await knx.assert_write(test_address, 0b10) + state = hass.states.get("select.test") + assert state.state == "Control - Off" + + # select another option + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test", "option": "No control"}, + blocking=True, + ) + await knx.assert_write(test_address, 0b00) + state = hass.states.get("select.test") + assert state.state == "No control" + + # don't answer to GroupValueRead requests by default + await knx.receive_read(test_address) + await knx.assert_no_telegram() + + # update from KNX + await knx.receive_write(test_address, 0b11) + state = hass.states.get("select.test") + assert state.state == "Control - On" + + # update from KNX with undefined value + await knx.receive_write(test_address, 0b01) + state = hass.states.get("select.test") + assert state.state is STATE_UNKNOWN + + # select invalid option + with pytest.raises(ValueError): + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test", "option": "invalid"}, + blocking=True, + ) + await knx.assert_no_telegram() + + +async def test_select_dpt_2_restore(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX select with passive_address and respond_to_read restoring state.""" + _options = [ + {SelectSchema.CONF_PAYLOAD: 0b00, SelectSchema.CONF_OPTION: "No control"}, + {SelectSchema.CONF_PAYLOAD: 0b10, SelectSchema.CONF_OPTION: "Control - Off"}, + {SelectSchema.CONF_PAYLOAD: 0b11, SelectSchema.CONF_OPTION: "Control - On"}, + ] + test_address = "1/1/1" + test_passive_address = "3/3/3" + fake_state = State("select.test", "Control - On") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + await knx.setup_integration( + { + SelectSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + SelectSchema.CONF_PAYLOAD_LENGTH: 0, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + # restored state - doesn't send telegram + state = hass.states.get("select.test") + assert state.state == "Control - On" + await knx.assert_telegram_count(0) + + # respond with restored state + await knx.receive_read(test_address) + await knx.assert_response(test_address, 3) + + # don't respond to passive address + await knx.receive_read(test_passive_address) + await knx.assert_no_telegram() + + +async def test_select_dpt_20_103_all_options(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX select with state_address, passive_address and respond_to_read.""" + _options = [ + {SelectSchema.CONF_PAYLOAD: 0, SelectSchema.CONF_OPTION: "Auto"}, + {SelectSchema.CONF_PAYLOAD: 1, SelectSchema.CONF_OPTION: "Legio protect"}, + {SelectSchema.CONF_PAYLOAD: 2, SelectSchema.CONF_OPTION: "Normal"}, + {SelectSchema.CONF_PAYLOAD: 3, SelectSchema.CONF_OPTION: "Reduced"}, + {SelectSchema.CONF_PAYLOAD: 4, SelectSchema.CONF_OPTION: "Off"}, + ] + test_address = "1/1/1" + test_state_address = "2/2/2" + test_passive_address = "3/3/3" + + await knx.setup_integration( + { + SelectSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_STATE_ADDRESS: test_state_address, + CONF_RESPOND_TO_READ: True, + SelectSchema.CONF_PAYLOAD_LENGTH: 1, + SelectSchema.CONF_OPTIONS: _options, + } + } + ) + assert len(hass.states.async_all()) == 1 + state = hass.states.get("select.test") + assert state.state is STATE_UNKNOWN + + # StateUpdater initialize state + await knx.assert_read(test_state_address) + await knx.receive_response(test_state_address, (2,)) + state = hass.states.get("select.test") + assert state.state == "Normal" + + # select an option + await hass.services.async_call( + "select", + "select_option", + {"entity_id": "select.test", "option": "Legio protect"}, + blocking=True, + ) + await knx.assert_write(test_address, (1,)) + state = hass.states.get("select.test") + assert state.state == "Legio protect" + + # answer to GroupValueRead requests + await knx.receive_read(test_address) + await knx.assert_response(test_address, (1,)) + + # update from KNX state_address + await knx.receive_write(test_state_address, (3,)) + state = hass.states.get("select.test") + assert state.state == "Reduced" + + # update from KNX passive_address + await knx.receive_write(test_passive_address, (4,)) + state = hass.states.get("select.test") + assert state.state == "Off" diff --git a/tests/components/knx/test_services.py b/tests/components/knx/test_services.py new file mode 100644 index 00000000000..80ed51e6aec --- /dev/null +++ b/tests/components/knx/test_services.py @@ -0,0 +1,166 @@ +"""Test KNX services.""" +from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant + +from .conftest import KNXTestKit + +from tests.common import async_capture_events + + +async def test_send(hass: HomeAssistant, knx: KNXTestKit): + """Test `knx.send` service.""" + test_address = "1/2/3" + await knx.setup_integration({}) + + # send DPT 1 telegram + await hass.services.async_call( + "knx", "send", {"address": test_address, "payload": True}, blocking=True + ) + await knx.assert_write(test_address, True) + + # send raw DPT 5 telegram + await hass.services.async_call( + "knx", "send", {"address": test_address, "payload": [99]}, blocking=True + ) + await knx.assert_write(test_address, (99,)) + + # send "percent" DPT 5 telegram + await hass.services.async_call( + "knx", + "send", + {"address": test_address, "payload": 99, "type": "percent"}, + blocking=True, + ) + await knx.assert_write(test_address, (0xFC,)) + + # send "temperature" DPT 9 telegram + await hass.services.async_call( + "knx", + "send", + {"address": test_address, "payload": 21.0, "type": "temperature"}, + blocking=True, + ) + await knx.assert_write(test_address, (0x0C, 0x1A)) + + # send multiple telegrams + await hass.services.async_call( + "knx", + "send", + {"address": [test_address, "2/2/2", "3/3/3"], "payload": 99, "type": "percent"}, + blocking=True, + ) + await knx.assert_write(test_address, (0xFC,)) + await knx.assert_write("2/2/2", (0xFC,)) + await knx.assert_write("3/3/3", (0xFC,)) + + +async def test_read(hass: HomeAssistant, knx: KNXTestKit): + """Test `knx.read` service.""" + await knx.setup_integration({}) + + # send read telegram + await hass.services.async_call("knx", "read", {"address": "1/1/1"}, blocking=True) + await knx.assert_read("1/1/1") + + # send multiple read telegrams + await hass.services.async_call( + "knx", + "read", + {"address": ["1/1/1", "2/2/2", "3/3/3"]}, + blocking=True, + ) + await knx.assert_read("1/1/1") + await knx.assert_read("2/2/2") + await knx.assert_read("3/3/3") + + +async def test_event_register(hass: HomeAssistant, knx: KNXTestKit): + """Test `knx.event_register` service.""" + events = async_capture_events(hass, "knx_event") + test_address = "1/2/3" + + await knx.setup_integration({}) + + # no event registered + await knx.receive_write(test_address, True) + await hass.async_block_till_done() + assert len(events) == 0 + + # register event + await hass.services.async_call( + "knx", "event_register", {"address": test_address}, blocking=True + ) + await knx.receive_write(test_address, True) + await knx.receive_write(test_address, False) + await hass.async_block_till_done() + assert len(events) == 2 + + # remove event registration - no event added + await hass.services.async_call( + "knx", + "event_register", + {"address": test_address, "remove": True}, + blocking=True, + ) + await knx.receive_write(test_address, True) + await hass.async_block_till_done() + assert len(events) == 2 + + +async def test_exposure_register(hass: HomeAssistant, knx: KNXTestKit): + """Test `knx.exposure_register` service.""" + test_address = "1/2/3" + test_entity = "fake.entity" + test_attribute = "fake_attribute" + + await knx.setup_integration({}) + + # no exposure registered + hass.states.async_set(test_entity, STATE_ON, {}) + await knx.assert_no_telegram() + + # register exposure + await hass.services.async_call( + "knx", + "exposure_register", + {"address": test_address, "entity_id": test_entity, "type": "binary"}, + blocking=True, + ) + hass.states.async_set(test_entity, STATE_OFF, {}) + await knx.assert_write(test_address, False) + + # register exposure + await hass.services.async_call( + "knx", + "exposure_register", + {"address": test_address, "remove": True}, + blocking=True, + ) + hass.states.async_set(test_entity, STATE_ON, {}) + await knx.assert_no_telegram() + + # register exposure for attribute with default + await hass.services.async_call( + "knx", + "exposure_register", + { + "address": test_address, + "entity_id": test_entity, + "attribute": test_attribute, + "type": "percentU8", + "default": 0, + }, + blocking=True, + ) + # no attribute on first change wouldn't work because no attribute change since last test + hass.states.async_set(test_entity, STATE_ON, {test_attribute: 30}) + await knx.assert_write(test_address, (30,)) + hass.states.async_set(test_entity, STATE_OFF, {}) + await knx.assert_write(test_address, (0,)) + # don't send same value sequentially + hass.states.async_set(test_entity, STATE_ON, {test_attribute: 25}) + hass.states.async_set(test_entity, STATE_ON, {test_attribute: 25}) + hass.states.async_set(test_entity, STATE_ON, {test_attribute: 25, "unrelated": 2}) + hass.states.async_set(test_entity, STATE_OFF, {test_attribute: 25}) + await knx.assert_telegram_count(1) + await knx.assert_write(test_address, (25,)) diff --git a/tests/components/knx/test_switch.py b/tests/components/knx/test_switch.py new file mode 100644 index 00000000000..eff34243ca8 --- /dev/null +++ b/tests/components/knx/test_switch.py @@ -0,0 +1,152 @@ +"""Test KNX switch.""" +from unittest.mock import patch + +from homeassistant.components.knx.const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + KNX_ADDRESS, +) +from homeassistant.components.knx.schema import SwitchSchema +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, State + +from .conftest import KNXTestKit + + +async def test_switch_simple(hass: HomeAssistant, knx: KNXTestKit): + """Test simple KNX switch.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/3", + } + } + ) + assert len(hass.states.async_all()) == 1 + + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/3", True) + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/3", False) + + # receive ON telegram + await knx.receive_write("1/2/3", True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram + await knx.receive_write("1/2/3", False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # switch does not respond to read by default + await knx.receive_read("1/2/3") + await knx.assert_telegram_count(0) + + +async def test_switch_state(hass: HomeAssistant, knx: KNXTestKit): + """Test KNX switch with state_address.""" + _ADDRESS = "1/1/1" + _STATE_ADDRESS = "2/2/2" + + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_STATE_ADDRESS: _STATE_ADDRESS, + }, + } + ) + assert len(hass.states.async_all()) == 1 + + # StateUpdater initialize state + await knx.assert_read(_STATE_ADDRESS) + await knx.receive_response(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram at `address` + await knx.receive_write(_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # receive ON telegram at `address` + await knx.receive_write(_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # receive OFF telegram at `state_address` + await knx.receive_write(_STATE_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state is STATE_OFF + + # receive ON telegram at `state_address` + await knx.receive_write(_STATE_ADDRESS, True) + state = hass.states.get("switch.test") + assert state.state is STATE_ON + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, False) + + # turn on switch + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, True) + + # switch does not respond to read by default + await knx.receive_read(_ADDRESS) + await knx.assert_telegram_count(0) + + +async def test_switch_restore_and_respond(hass, knx): + """Test restoring KNX switch state and respond to read.""" + _ADDRESS = "1/1/1" + fake_state = State("switch.test", "on") + + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", + return_value=fake_state, + ): + await knx.setup_integration( + { + SwitchSchema.PLATFORM_NAME: { + CONF_NAME: "test", + KNX_ADDRESS: _ADDRESS, + CONF_RESPOND_TO_READ: True, + }, + } + ) + + # restored state - doesn't send telegram + state = hass.states.get("switch.test") + assert state.state == STATE_ON + await knx.assert_telegram_count(0) + + # respond to restored state + await knx.receive_read(_ADDRESS) + await knx.assert_response(_ADDRESS, True) + + # turn off switch + await hass.services.async_call( + "switch", "turn_off", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write(_ADDRESS, False) + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + # respond to new state + await knx.receive_read(_ADDRESS) + await knx.assert_response(_ADDRESS, False) diff --git a/tests/components/litejet/test_config_flow.py b/tests/components/litejet/test_config_flow.py index 1d72324f484..cfae178f792 100644 --- a/tests/components/litejet/test_config_flow.py +++ b/tests/components/litejet/test_config_flow.py @@ -3,8 +3,8 @@ from unittest.mock import patch from serial import SerialException -from homeassistant import config_entries -from homeassistant.components.litejet.const import DOMAIN +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.litejet.const import CONF_DEFAULT_TRANSITION, DOMAIN from homeassistant.const import CONF_PORT from tests.common import MockConfigEntry @@ -76,3 +76,22 @@ async def test_import_step(hass): assert result["type"] == "create_entry" assert result["title"] == test_data[CONF_PORT] assert result["data"] == test_data + + +async def test_options(hass): + """Test updating options.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_PORT: "/dev/test"}) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_DEFAULT_TRANSITION: 12}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_DEFAULT_TRANSITION: 12} diff --git a/tests/components/litejet/test_light.py b/tests/components/litejet/test_light.py index c455d3a960e..1961843c8b0 100644 --- a/tests/components/litejet/test_light.py +++ b/tests/components/litejet/test_light.py @@ -2,7 +2,8 @@ import logging from homeassistant.components import light -from homeassistant.components.light import ATTR_BRIGHTNESS +from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_TRANSITION +from homeassistant.components.litejet.const import CONF_DEFAULT_TRANSITION from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from . import async_init_integration @@ -33,6 +34,55 @@ async def test_on_brightness(hass, mock_litejet): mock_litejet.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 39, 0) +async def test_default_transition(hass, mock_litejet): + """Test turning the light on with the default transition option.""" + entry = await async_init_integration(hass) + + hass.config_entries.async_update_entry(entry, options={CONF_DEFAULT_TRANSITION: 12}) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_LIGHT).state == "off" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" + + assert not light.is_on(hass, ENTITY_LIGHT) + + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_BRIGHTNESS: 102}, + blocking=True, + ) + mock_litejet.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 39, 12) + + +async def test_transition(hass, mock_litejet): + """Test turning the light on with transition.""" + await async_init_integration(hass) + + assert hass.states.get(ENTITY_LIGHT).state == "off" + assert hass.states.get(ENTITY_OTHER_LIGHT).state == "off" + + assert not light.is_on(hass, ENTITY_LIGHT) + + # On + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: 5}, + blocking=True, + ) + mock_litejet.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 99, 5) + + # Off + await hass.services.async_call( + light.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_LIGHT, ATTR_TRANSITION: 5}, + blocking=True, + ) + mock_litejet.activate_load_at.assert_called_with(ENTITY_LIGHT_NUMBER, 0, 5) + + async def test_on_off(hass, mock_litejet): """Test turning the light on and off.""" await async_init_integration(hass) @@ -91,9 +141,7 @@ async def test_activated_event(hass, mock_litejet): assert light.is_on(hass, ENTITY_OTHER_LIGHT) assert hass.states.get(ENTITY_LIGHT).state == "on" assert hass.states.get(ENTITY_OTHER_LIGHT).state == "on" - assert ( - int(hass.states.get(ENTITY_OTHER_LIGHT).attributes.get(ATTR_BRIGHTNESS)) == 103 - ) + assert hass.states.get(ENTITY_OTHER_LIGHT).attributes.get(ATTR_BRIGHTNESS) == 103 async def test_deactivated_event(hass, mock_litejet): diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 237317545a1..c408ed28819 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -1,5 +1,7 @@ """Configure pytest for Litter-Robot tests.""" -from typing import Any, Optional +from __future__ import annotations + +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from pylitterbot import Account, Robot @@ -15,7 +17,7 @@ from tests.common import MockConfigEntry def create_mock_robot( - robot_data: Optional[dict] = None, side_effect: Optional[Any] = None + robot_data: dict | None = None, side_effect: Any | None = None ) -> Robot: """Create a mock Litter-Robot device.""" if not robot_data: @@ -33,8 +35,8 @@ def create_mock_robot( def create_mock_account( - robot_data: Optional[dict] = None, - side_effect: Optional[Any] = None, + robot_data: dict | None = None, + side_effect: Any | None = None, skip_robots: bool = False, ) -> MagicMock: """Create a mock Litter-Robot account.""" @@ -72,7 +74,7 @@ def mock_account_with_side_effects() -> MagicMock: async def setup_integration( - hass: HomeAssistant, mock_account: MagicMock, platform_domain: Optional[str] = None + hass: HomeAssistant, mock_account: MagicMock, platform_domain: str | None = None ) -> MockConfigEntry: """Load a Litter-Robot platform with the provided hub.""" entry = MockConfigEntry( diff --git a/tests/components/local_ip/test_init.py b/tests/components/local_ip/test_init.py index a7ebfba28e2..be1e689ca16 100644 --- a/tests/components/local_ip/test_init.py +++ b/tests/components/local_ip/test_init.py @@ -1,6 +1,7 @@ """Tests for the local_ip component.""" from homeassistant.components.local_ip import DOMAIN -from homeassistant.util import get_local_ip +from homeassistant.components.network import async_get_source_ip +from homeassistant.components.zeroconf import MDNS_TARGET_IP from tests.common import MockConfigEntry @@ -13,7 +14,7 @@ async def test_basic_setup(hass): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - local_ip = await hass.async_add_executor_job(get_local_ip) + local_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) state = hass.states.get(f"sensor.{DOMAIN}") assert state assert state.state == local_ip diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index b021ef23391..aeb304cb1c8 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -3,7 +3,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -60,6 +66,27 @@ async def test_get_conditions(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] conditions = await async_get_device_automations(hass, "condition", device_entry.id) assert_lists_same(conditions, expected_conditions) @@ -110,6 +137,60 @@ async def test_if_state(hass, calls): }, }, }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_unlocking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_unlocking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event4"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_locking", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_locking - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event5"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "lock.entity", + "type": "is_jammed", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_jammed - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, ] }, ) @@ -125,3 +206,21 @@ async def test_if_state(hass, calls): await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "is_unlocked - event - test_event2" + + hass.states.async_set("lock.entity", STATE_UNLOCKING) + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "is_unlocking - event - test_event3" + + hass.states.async_set("lock.entity", STATE_LOCKING) + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "is_locking - event - test_event4" + + hass.states.async_set("lock.entity", STATE_JAMMED) + hass.bus.async_fire("test_event5") + await hass.async_block_till_done() + assert len(calls) == 5 + assert calls[4].data["some"] == "is_jammed - event - test_event5" diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index d4d96927b56..c3539288f94 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -5,7 +5,13 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.lock import DOMAIN -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, +) from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -65,6 +71,27 @@ async def test_get_triggers(hass, device_reg, entity_reg): "device_id": device_entry.id, "entity_id": f"{DOMAIN}.test_5678", }, + { + "platform": "device", + "domain": DOMAIN, + "type": "unlocking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "locking", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "jammed", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, ] triggers = await async_get_device_automations(hass, "trigger", device_entry.id) assert_lists_same(triggers, expected_triggers) @@ -81,7 +108,7 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) triggers = await async_get_device_automations(hass, "trigger", device_entry.id) - assert len(triggers) == 2 + assert len(triggers) == 5 for trigger in triggers: capabilities = await async_get_device_automation_capabilities( hass, "trigger", trigger @@ -195,7 +222,82 @@ async def test_if_fires_on_state_change_with_for(hass, calls): ) }, }, - } + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "unlocking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "jammed", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": entity_id, + "type": "locking", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, ] }, ) @@ -214,3 +316,39 @@ async def test_if_fires_on_state_change_with_for(hass, calls): calls[0].data["some"] == f"turn_off device - {entity_id} - unlocked - locked - 0:00:05" ) + + hass.states.async_set(entity_id, STATE_UNLOCKING) + await hass.async_block_till_done() + assert len(calls) == 1 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=16)) + await hass.async_block_till_done() + assert len(calls) == 2 + await hass.async_block_till_done() + assert ( + calls[1].data["some"] + == f"turn_on device - {entity_id} - locked - unlocking - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_JAMMED) + await hass.async_block_till_done() + assert len(calls) == 2 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=21)) + await hass.async_block_till_done() + assert len(calls) == 3 + await hass.async_block_till_done() + assert ( + calls[2].data["some"] + == f"turn_off device - {entity_id} - unlocking - jammed - 0:00:05" + ) + + hass.states.async_set(entity_id, STATE_LOCKING) + await hass.async_block_till_done() + assert len(calls) == 3 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=27)) + await hass.async_block_till_done() + assert len(calls) == 4 + await hass.async_block_till_done() + assert ( + calls[3].data["some"] + == f"turn_on device - {entity_id} - jammed - locking - 0:00:05" + ) diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 75ecc8d9db3..8f75085f9ee 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -43,7 +43,7 @@ async def test_get_media_from_mailbox(mock_http_client): msgtxt = "Message 1. Lorem ipsum dolor sit amet, consectetur adipiscing elit. " msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() - url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha) + url = f"/api/mailbox/media/DemoMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == 200 data = await req.read() @@ -58,7 +58,7 @@ async def test_delete_from_mailbox(mock_http_client): msgsha2 = sha1(msgtxt2.encode("utf-8")).hexdigest() for msg in [msgsha1, msgsha2]: - url = "/api/mailbox/delete/DemoMailbox/%s" % (msg) + url = f"/api/mailbox/delete/DemoMailbox/{msg}" req = await mock_http_client.delete(url) assert req.status == 200 @@ -80,7 +80,7 @@ async def test_get_messages_from_invalid_mailbox(mock_http_client): async def test_get_media_from_invalid_mailbox(mock_http_client): """Get messages from mailbox.""" msgsha = "0000000000000000000000000000000000000000" - url = "/api/mailbox/media/mailbox.invalid_mailbox/%s" % (msgsha) + url = f"/api/mailbox/media/mailbox.invalid_mailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTP_NOT_FOUND @@ -89,7 +89,7 @@ async def test_get_media_from_invalid_mailbox(mock_http_client): async def test_get_media_from_invalid_msgid(mock_http_client): """Get messages from mailbox.""" msgsha = "0000000000000000000000000000000000000000" - url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha) + url = f"/api/mailbox/media/DemoMailbox/{msgsha}" req = await mock_http_client.get(url) assert req.status == HTTP_INTERNAL_SERVER_ERROR @@ -98,7 +98,7 @@ async def test_get_media_from_invalid_msgid(mock_http_client): async def test_delete_from_invalid_mailbox(mock_http_client): """Get audio from mailbox.""" msgsha = "0000000000000000000000000000000000000000" - url = "/api/mailbox/delete/mailbox.invalid_mailbox/%s" % (msgsha) + url = f"/api/mailbox/delete/mailbox.invalid_mailbox/{msgsha}" req = await mock_http_client.delete(url) assert req.status == HTTP_NOT_FOUND diff --git a/tests/components/melcloud/test_atw_zone_sensor.py b/tests/components/melcloud/test_atw_zone_sensor.py index 6e6487a3774..5938f1af1f1 100644 --- a/tests/components/melcloud/test_atw_zone_sensor.py +++ b/tests/components/melcloud/test_atw_zone_sensor.py @@ -37,15 +37,13 @@ def test_zone_unique_ids(mock_device, mock_zone_1, mock_zone_2): sensor_1 = AtwZoneSensor( mock_device, mock_zone_1, - "room_temperature", - ATW_ZONE_SENSORS["room_temperature"], + ATW_ZONE_SENSORS[0], # room_temperature ) assert sensor_1.unique_id == "1234-11:11:11:11:11:11-room_temperature" sensor_2 = AtwZoneSensor( mock_device, mock_zone_2, - "room_temperature", - ATW_ZONE_SENSORS["flow_temperature"], + ATW_ZONE_SENSORS[0], # room_temperature ) assert sensor_2.unique_id == "1234-11:11:11:11:11:11-room_temperature-zone-2" diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py index 6103c43d3a4..1f5cc5fd04f 100644 --- a/tests/components/mfi/test_sensor.py +++ b/tests/components/mfi/test_sensor.py @@ -7,7 +7,7 @@ import requests import homeassistant.components.mfi.sensor as mfi import homeassistant.components.sensor as sensor_component -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.setup import async_setup_component PLATFORM = mfi @@ -133,30 +133,35 @@ async def test_uom_temp(port, sensor): """Test the UOM temperature.""" port.tag = "temperature" assert sensor.unit_of_measurement == TEMP_CELSIUS + assert sensor.device_class == DEVICE_CLASS_TEMPERATURE async def test_uom_power(port, sensor): """Test the UOEM power.""" port.tag = "active_pwr" assert sensor.unit_of_measurement == "Watts" + assert sensor.device_class is None async def test_uom_digital(port, sensor): """Test the UOM digital input.""" port.model = "Input Digital" assert sensor.unit_of_measurement == "State" + assert sensor.device_class is None async def test_uom_unknown(port, sensor): """Test the UOM.""" port.tag = "balloons" assert sensor.unit_of_measurement == "balloons" + assert sensor.device_class is None async def test_uom_uninitialized(port, sensor): """Test that the UOM defaults if not initialized.""" type(port).tag = mock.PropertyMock(side_effect=ValueError) assert sensor.unit_of_measurement == "State" + assert sensor.device_class is None async def test_state_digital(port, sensor): diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index e9c178ff025..e77fd380a22 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SLAVE, STATE_OFF, STATE_ON, @@ -144,6 +145,7 @@ async def test_service_binary_sensor_update(hass, mock_pymodbus): { CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 0, } ] }, diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index b58822644be..97d2c32ba69 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -3,7 +3,15 @@ import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.climate.const import HVAC_MODE_AUTO -from homeassistant.components.modbus.const import CONF_CLIMATES, CONF_TARGET_TEMP +from homeassistant.components.modbus.const import ( + CONF_CLIMATES, + CONF_DATA_TYPE, + CONF_TARGET_TEMP, + DATA_TYPE_FLOAT32, + DATA_TYPE_FLOAT64, + DATA_TYPE_INT16, + DATA_TYPE_INT32, +) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_ADDRESS, @@ -96,6 +104,7 @@ async def test_service_climate_update(hass, mock_pymodbus): CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, CONF_SLAVE: 10, + CONF_SCAN_INTERVAL: 0, } ] } @@ -110,6 +119,46 @@ async def test_service_climate_update(hass, mock_pymodbus): assert hass.states.get(ENTITY_ID).state == "auto" +@pytest.mark.parametrize( + "data_type, temperature, result", + [ + (DATA_TYPE_INT16, 35, [0x00]), + (DATA_TYPE_INT32, 36, [0x00, 0x00]), + (DATA_TYPE_FLOAT32, 37.5, [0x00, 0x00]), + (DATA_TYPE_FLOAT64, "39", [0x00, 0x00, 0x00, 0x00]), + ], +) +async def test_service_climate_set_temperature( + hass, data_type, temperature, result, mock_pymodbus +): + """Run test for service homeassistant.update_entity.""" + config = { + CONF_CLIMATES: [ + { + CONF_NAME: CLIMATE_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_SLAVE: 10, + CONF_DATA_TYPE: data_type, + } + ] + } + mock_pymodbus.read_holding_registers.return_value = ReadResult(result) + await prepare_service_update( + hass, + config, + ) + await hass.services.async_call( + CLIMATE_DOMAIN, + "set_temperature", + { + "entity_id": ENTITY_ID, + ATTR_TEMPERATURE: temperature, + }, + blocking=True, + ) + + test_value = State(ENTITY_ID, 35) test_value.attributes = {ATTR_TEMPERATURE: 37} @@ -128,6 +177,7 @@ test_value.attributes = {ATTR_TEMPERATURE: 37} CONF_NAME: CLIMATE_NAME, CONF_TARGET_TEMP: 117, CONF_ADDRESS: 117, + CONF_SCAN_INTERVAL: 0, } ], }, diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 37274603bee..8d7e7e39cf8 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -211,6 +211,7 @@ async def test_service_cover_update(hass, mock_pymodbus): CONF_STATE_CLOSING: 3, CONF_STATUS_REGISTER: 1234, CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -232,11 +233,13 @@ async def test_service_cover_move(hass, mock_pymodbus): CONF_NAME: COVER_NAME, CONF_ADDRESS: 1234, CONF_STATUS_REGISTER_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{COVER_NAME}2", CONF_INPUT_TYPE: CALL_TYPE_COIL, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, }, ] } diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 4eeb094130b..13714d6bd0e 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_TYPE, STATE_OFF, @@ -195,6 +196,7 @@ async def test_all_fan(hass, call_type, regs, verify, expected): { CONF_NAME: FAN_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -219,11 +221,13 @@ async def test_fan_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: FAN_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{FAN_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, }, ], diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 6349d6bffe3..8b8d063bf02 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -55,7 +55,7 @@ from homeassistant.components.modbus.const import ( ) from homeassistant.components.modbus.validators import ( number_validator, - sensor_schema_validator, + struct_validator, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -144,12 +144,12 @@ async def test_number_validator(): }, ], ) -async def test_ok_sensor_schema_validator(do_config): +async def test_ok_struct_validator(do_config): """Test struct validator.""" try: - sensor_schema_validator(do_config) + struct_validator(do_config) except vol.Invalid: - pytest.fail("Sensor_schema_validator unexpected exception") + pytest.fail("struct_validator unexpected exception") @pytest.mark.parametrize( @@ -180,18 +180,19 @@ async def test_ok_sensor_schema_validator(do_config): { CONF_NAME: TEST_SENSOR_NAME, CONF_COUNT: 1, - CONF_DATA_TYPE: DATA_TYPE_INT, + CONF_DATA_TYPE: DATA_TYPE_CUSTOM, + CONF_STRUCTURE: ">f", CONF_SWAP: CONF_SWAP_WORD, }, ], ) -async def test_exception_sensor_schema_validator(do_config): +async def test_exception_struct_validator(do_config): """Test struct validator.""" try: - sensor_schema_validator(do_config) + struct_validator(do_config) except vol.Invalid: return - pytest.fail("Sensor_schema_validator missing exception") + pytest.fail("struct_validator missing exception") @pytest.mark.parametrize( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index e962b69a2a6..c7b9b820934 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_LIGHTS, CONF_NAME, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SLAVE, CONF_TYPE, STATE_OFF, @@ -195,6 +196,7 @@ async def test_all_light(hass, call_type, regs, verify, expected): { CONF_NAME: LIGHT_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -219,11 +221,13 @@ async def test_light_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: LIGHT_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{LIGHT_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, }, ], diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f9bc8454281..f01a3ef9da5 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_NAME, CONF_OFFSET, + CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SLAVE, CONF_STRUCTURE, @@ -135,15 +136,6 @@ async def test_config_sensor(hass, mock_modbus): @pytest.mark.parametrize( "do_config,error_message", [ - ( - { - CONF_ADDRESS: 1234, - CONF_COUNT: 8, - CONF_PRECISION: 2, - CONF_DATA_TYPE: DATA_TYPE_INT, - }, - "Unable to detect data type for test_sensor sensor, try a custom type", - ), ( { CONF_ADDRESS: 1234, @@ -152,7 +144,7 @@ async def test_config_sensor(hass, mock_modbus): CONF_DATA_TYPE: DATA_TYPE_CUSTOM, CONF_STRUCTURE: ">no struct", }, - "Error in sensor test_sensor structure: bad char in struct format", + "bad char in struct format", ), ( { @@ -172,7 +164,7 @@ async def test_config_sensor(hass, mock_modbus): CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "invalid", }, - "Error in sensor test_sensor structure: bad char in struct format", + "bad char in struct format", ), ( { @@ -182,7 +174,7 @@ async def test_config_sensor(hass, mock_modbus): CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "", }, - "Error in sensor test_sensor. The `structure` field can not be empty if the parameter `data_type` is set to the `custom`", + "Error in sensor test_sensor. The `structure` field can not be empty", ), ( { @@ -229,7 +221,7 @@ async def test_config_wrong_struct_sensor( expect_setup_to_fail=True, ) - assert error_message in caplog.text + assert caplog.text.count(error_message) @pytest.mark.parametrize( @@ -587,6 +579,7 @@ async def test_struct_sensor(hass, cfg, regs, expected): { CONF_NAME: SENSOR_NAME, CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -597,46 +590,6 @@ async def test_restore_state_sensor(hass, mock_test_state, mock_modbus): assert hass.states.get(ENTITY_ID).state == mock_test_state[0].state -@pytest.mark.parametrize( - "swap_type, error_message", - [ - ( - CONF_SWAP_WORD, - f"Error in sensor {SENSOR_NAME} swap(word) not possible due to the registers count: 1, needed: 2", - ), - ( - CONF_SWAP_WORD_BYTE, - f"Error in sensor {SENSOR_NAME} swap(word_byte) not possible due to the registers count: 1, needed: 2", - ), - ], -) -async def test_swap_sensor_wrong_config( - hass, caplog, swap_type, error_message, mock_pymodbus -): - """Run test for sensor swap.""" - config = { - CONF_NAME: SENSOR_NAME, - CONF_ADDRESS: 1234, - CONF_COUNT: 1, - CONF_SWAP: swap_type, - CONF_DATA_TYPE: DATA_TYPE_INT, - } - - caplog.set_level(logging.ERROR) - caplog.clear() - await base_config_test( - hass, - config, - SENSOR_NAME, - SENSOR_DOMAIN, - CONF_SENSORS, - None, - method_discovery=True, - expect_setup_to_fail=True, - ) - assert error_message in "".join(caplog.messages) - - async def test_service_sensor_update(hass, mock_pymodbus): """Run test for service homeassistant.update_entity.""" config = { diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index b31ca12c48b..c620429aad2 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -210,6 +210,7 @@ async def test_all_switch(hass, call_type, regs, verify, expected): { CONF_NAME: SWITCH_NAME, CONF_ADDRESS: 1234, + CONF_SCAN_INTERVAL: 0, } ] }, @@ -234,11 +235,13 @@ async def test_switch_service_turn(hass, caplog, mock_pymodbus): CONF_NAME: SWITCH_NAME, CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, }, { CONF_NAME: f"{SWITCH_NAME}2", CONF_ADDRESS: 17, CONF_WRITE_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_SCAN_INTERVAL: 0, CONF_VERIFY: {}, }, ], diff --git a/tests/components/motioneye/__init__.py b/tests/components/motioneye/__init__.py index ed91d7c40a3..dcc030e7e5b 100644 --- a/tests/components/motioneye/__init__.py +++ b/tests/components/motioneye/__init__.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock, patch from motioneye_client.const import DEFAULT_PORT from homeassistant.components.motioneye.const import DOMAIN +from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -132,6 +133,10 @@ TEST_CAMERA = { } TEST_CAMERAS = {"cameras": [TEST_CAMERA]} TEST_SURVEILLANCE_USERNAME = "surveillance_username" +TEST_SWITCH_ENTITY_ID_BASE = "switch.test_camera" +TEST_SWITCH_MOTION_DETECTION_ENTITY_ID = ( + f"{TEST_SWITCH_ENTITY_ID_BASE}_motion_detection" +) def create_mock_motioneye_client() -> AsyncMock: @@ -151,14 +156,14 @@ def create_mock_motioneye_config_entry( options: dict[str, Any] | None = None, ) -> ConfigEntry: """Add a test config entry.""" - config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call] + config_entry: MockConfigEntry = MockConfigEntry( entry_id=TEST_CONFIG_ENTRY_ID, domain=DOMAIN, data=data or {CONF_URL: TEST_URL}, title=f"{TEST_URL}", options=options or {}, ) - config_entry.add_to_hass(hass) # type: ignore[no-untyped-call] + config_entry.add_to_hass(hass) return config_entry @@ -167,7 +172,13 @@ async def setup_mock_motioneye_config_entry( config_entry: ConfigEntry | None = None, client: Mock | None = None, ) -> ConfigEntry: - """Add a mock MotionEye config entry to hass.""" + """Create and setup a mock motionEye config entry.""" + + await async_process_ha_core_config( + hass, + {"external_url": "https://example.com"}, + ) + config_entry = config_entry or create_mock_motioneye_config_entry(hass) client = client or create_mock_motioneye_client() diff --git a/tests/components/motioneye/test_camera.py b/tests/components/motioneye/test_camera.py index f1ddcea4386..70c2d44436a 100644 --- a/tests/components/motioneye/test_camera.py +++ b/tests/components/motioneye/test_camera.py @@ -1,7 +1,7 @@ """Test the motionEye camera.""" import copy import logging -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, Mock from aiohttp import web @@ -235,7 +235,7 @@ async def test_get_still_image_from_camera( # It won't actually get a stream from the dummy handler, so just catch # the expected exception, then verify the right handler was called. with pytest.raises(HomeAssistantError): - await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=None) # type: ignore[no-untyped-call] + await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=1) assert image_handler.called @@ -269,7 +269,9 @@ async def test_get_stream_from_camera(aiohttp_server: Any, hass: HomeAssistant) # It won't actually get a stream from the dummy handler, so just catch # the expected exception, then verify the right handler was called. with pytest.raises(HTTPBadGateway): - await async_get_mjpeg_stream(hass, None, TEST_CAMERA_ENTITY_ID) # type: ignore[no-untyped-call] + await async_get_mjpeg_stream( + hass, cast(web.Request, None), TEST_CAMERA_ENTITY_ID + ) assert stream_handler.called @@ -296,8 +298,7 @@ async def test_state_attributes(hass: HomeAssistant) -> None: async def test_device_info(hass: HomeAssistant) -> None: """Verify device information includes expected details.""" - client = create_mock_motioneye_client() - entry = await setup_mock_motioneye_config_entry(hass, client=client) + entry = await setup_mock_motioneye_config_entry(hass) device_identifier = get_motioneye_device_identifier(entry.entry_id, TEST_CAMERA_ID) device_registry = dr.async_get(hass) diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index fbdabdadb41..604085cec8f 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -14,9 +14,11 @@ from homeassistant.components.motioneye.const import ( CONF_ADMIN_USERNAME, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, + CONF_WEBHOOK_SET, + CONF_WEBHOOK_SET_OVERWRITE, DOMAIN, ) -from homeassistant.const import CONF_URL +from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from . import TEST_URL, create_mock_motioneye_client, create_mock_motioneye_config_entry @@ -247,6 +249,7 @@ async def test_reauth(hass: HomeAssistant) -> None: """Test a reauth.""" config_data = { CONF_URL: TEST_URL, + CONF_WEBHOOK_ID: "test-webhook-id", } config_entry = create_mock_motioneye_config_entry(hass, data=config_data) @@ -287,7 +290,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "reauth_successful" - assert dict(config_entry.data) == new_data + assert dict(config_entry.data) == {**new_data, CONF_WEBHOOK_ID: "test-webhook-id"} assert len(mock_setup_entry.mock_calls) == 1 assert mock_client.async_client_close.called @@ -300,11 +303,11 @@ async def test_duplicate(hass: HomeAssistant) -> None: } # Add an existing entry with the same URL. - existing_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call] + existing_entry: MockConfigEntry = MockConfigEntry( domain=DOMAIN, data=config_data, ) - existing_entry.add_to_hass(hass) # type: ignore[no-untyped-call] + existing_entry.add_to_hass(hass) # Now do the usual config entry process, and verify it is rejected. create_mock_motioneye_config_entry(hass, data=config_data) @@ -431,3 +434,35 @@ async def test_hassio_clean_up_on_user_flow(hass: HomeAssistant) -> None: flows = hass.config_entries.flow.async_progress() assert len(flows) == 0 + + +async def test_options(hass: HomeAssistant) -> None: + """Check an options flow.""" + + config_entry = create_mock_motioneye_config_entry(hass) + + client = create_mock_motioneye_client() + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=client, + ), patch( + "homeassistant.components.motioneye.async_setup_entry", + return_value=True, + ): + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_WEBHOOK_SET: True, + CONF_WEBHOOK_SET_OVERWRITE: True, + }, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"][CONF_WEBHOOK_SET] + assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] diff --git a/tests/components/motioneye/test_switch.py b/tests/components/motioneye/test_switch.py new file mode 100644 index 00000000000..05de2f0bbcf --- /dev/null +++ b/tests/components/motioneye/test_switch.py @@ -0,0 +1,204 @@ +"""Tests for the motionEye switch platform.""" +import copy +from datetime import timedelta +from unittest.mock import AsyncMock, call, patch + +from motioneye_client.const import ( + KEY_MOTION_DETECTION, + KEY_MOVIES, + KEY_STILL_IMAGES, + KEY_TEXT_OVERLAY, + KEY_UPLOAD_ENABLED, + KEY_VIDEO_STREAMING, +) + +from homeassistant.components.motioneye import get_motioneye_device_identifier +from homeassistant.components.motioneye.const import DEFAULT_SCAN_INTERVAL +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.util.dt as dt_util + +from . import ( + TEST_CAMERA, + TEST_CAMERA_ID, + TEST_CAMERAS, + TEST_SWITCH_ENTITY_ID_BASE, + TEST_SWITCH_MOTION_DETECTION_ENTITY_ID, + create_mock_motioneye_client, + setup_mock_motioneye_config_entry, +) + +from tests.common import async_fire_time_changed + + +async def test_switch_turn_on_off(hass: HomeAssistant) -> None: + """Test turning the switch on and off.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + # Verify switch is on. + entity_state = hass.states.get(TEST_SWITCH_MOTION_DETECTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "on" + + client.async_get_camera = AsyncMock(return_value=TEST_CAMERA) + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_MOTION_DETECTION] = False + + # When the next refresh is called return the updated values. + client.async_get_cameras = AsyncMock(return_value={"cameras": [expected_camera]}) + + # Turn switch off. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_SWITCH_MOTION_DETECTION_ENTITY_ID}, + blocking=True, + ) + + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + # Verify correct parameters are passed to the library. + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + # Verify the switch turns off. + entity_state = hass.states.get(TEST_SWITCH_MOTION_DETECTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "off" + + # When the next refresh is called return the updated values. + client.async_get_cameras = AsyncMock(return_value={"cameras": [TEST_CAMERA]}) + + # Turn switch on. + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_SWITCH_MOTION_DETECTION_ENTITY_ID}, + blocking=True, + ) + + # Verify correct parameters are passed to the library. + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, TEST_CAMERA) + + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + # Verify the switch turns on. + entity_state = hass.states.get(TEST_SWITCH_MOTION_DETECTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "on" + + +async def test_switch_state_update_from_coordinator(hass: HomeAssistant) -> None: + """Test that coordinator data impacts state.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + # Verify switch is on. + entity_state = hass.states.get(TEST_SWITCH_MOTION_DETECTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "on" + + updated_cameras = copy.deepcopy(TEST_CAMERAS) + updated_cameras["cameras"][0][KEY_MOTION_DETECTION] = False + client.async_get_cameras = AsyncMock(return_value=updated_cameras) + + async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL) + await hass.async_block_till_done() + + # Verify the switch turns off. + entity_state = hass.states.get(TEST_SWITCH_MOTION_DETECTION_ENTITY_ID) + assert entity_state + assert entity_state.state == "off" + + +async def test_switch_has_correct_entities(hass: HomeAssistant) -> None: + """Test that the correct switch entities are created.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + enabled_switch_keys = [ + KEY_MOTION_DETECTION, + KEY_STILL_IMAGES, + KEY_MOVIES, + ] + disabled_switch_keys = [ + KEY_TEXT_OVERLAY, + KEY_UPLOAD_ENABLED, + KEY_VIDEO_STREAMING, + ] + + for switch_key in enabled_switch_keys: + entity_id = f"{TEST_SWITCH_ENTITY_ID_BASE}_{switch_key}" + entity_state = hass.states.get(entity_id) + assert entity_state, f"Couldn't find entity: {entity_id}" + + for switch_key in disabled_switch_keys: + entity_id = f"{TEST_SWITCH_ENTITY_ID_BASE}_{switch_key}" + entity_state = hass.states.get(entity_id) + assert not entity_state + + +async def test_disabled_switches_can_be_enabled(hass: HomeAssistant) -> None: + """Verify disabled switches can be enabled.""" + client = create_mock_motioneye_client() + await setup_mock_motioneye_config_entry(hass, client=client) + + disabled_switch_keys = [ + KEY_TEXT_OVERLAY, + KEY_UPLOAD_ENABLED, + ] + + for switch_key in disabled_switch_keys: + entity_id = f"{TEST_SWITCH_ENTITY_ID_BASE}_{switch_key}" + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by == er.DISABLED_INTEGRATION + entity_state = hass.states.get(entity_id) + assert not entity_state + + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=client, + ): + updated_entry = entity_registry.async_update_entity( + entity_id, disabled_by=None + ) + assert not updated_entry.disabled + 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() + + entity_state = hass.states.get(entity_id) + assert entity_state + + +async def test_switch_device_info(hass: HomeAssistant) -> None: + """Verify device information includes expected details.""" + config_entry = await setup_mock_motioneye_config_entry(hass) + + device_identifer = get_motioneye_device_identifier( + config_entry.entry_id, TEST_CAMERA_ID + ) + device_registry = dr.async_get(hass) + + device = device_registry.async_get_device({device_identifer}) + assert device + + entity_registry = await er.async_get_registry(hass) + entities_from_device = [ + entry.entity_id + for entry in er.async_entries_for_device(entity_registry, device.id) + ] + assert TEST_SWITCH_MOTION_DETECTION_ENTITY_ID in entities_from_device diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py new file mode 100644 index 00000000000..03b4e8bc46a --- /dev/null +++ b/tests/components/motioneye/test_web_hooks.py @@ -0,0 +1,348 @@ +"""Test the motionEye camera web hooks.""" +import copy +import logging +from typing import Any +from unittest.mock import AsyncMock, call, patch + +from motioneye_client.const import ( + KEY_CAMERAS, + KEY_HTTP_METHOD_POST_JSON, + KEY_WEB_HOOK_NOTIFICATIONS_ENABLED, + KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD, + KEY_WEB_HOOK_NOTIFICATIONS_URL, + KEY_WEB_HOOK_STORAGE_ENABLED, + KEY_WEB_HOOK_STORAGE_HTTP_METHOD, + KEY_WEB_HOOK_STORAGE_URL, +) + +from homeassistant.components.motioneye.const import ( + ATTR_EVENT_TYPE, + CONF_WEBHOOK_SET_OVERWRITE, + DOMAIN, + EVENT_FILE_STORED, + EVENT_MOTION_DETECTED, +) +from homeassistant.components.webhook import URL_WEBHOOK_PATH +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_URL, + CONF_WEBHOOK_ID, + HTTP_BAD_REQUEST, + HTTP_OK, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import ( + TEST_CAMERA, + TEST_CAMERA_DEVICE_IDENTIFIER, + TEST_CAMERA_ID, + TEST_CAMERA_NAME, + TEST_CAMERAS, + TEST_URL, + create_mock_motioneye_client, + create_mock_motioneye_config_entry, + setup_mock_motioneye_config_entry, +) + +from tests.common import async_capture_events + +_LOGGER = logging.getLogger(__name__) + + +WEB_HOOK_MOTION_DETECTED_QUERY_STRING = ( + "camera_id=%t&changed_pixels=%D&despeckle_labels=%Q&event=%v&fps=%{fps}" + "&frame_number=%q&height=%h&host=%{host}&motion_center_x=%K&motion_center_y=%L" + "&motion_height=%J&motion_version=%{ver}&motion_width=%i&noise_level=%N" + "&threshold=%o&width=%w&src=hass-motioneye&event_type=motion_detected" +) + +WEB_HOOK_FILE_STORED_QUERY_STRING = ( + "camera_id=%t&event=%v&file_path=%f&file_type=%n&fps=%{fps}&frame_number=%q" + "&height=%h&host=%{host}&motion_version=%{ver}&noise_level=%N&threshold=%o&width=%w" + "&src=hass-motioneye&event_type=file_stored" +) + + +async def test_setup_camera_without_webhook(hass: HomeAssistant) -> None: + """Test a camera with no webhook.""" + client = create_mock_motioneye_client() + config_entry = await setup_mock_motioneye_config_entry(hass, client=client) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_device( + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} + ) + assert device + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" + ) + + expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True + expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" + ) + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_setup_camera_with_wrong_webhook( + hass: HomeAssistant, +) -> None: + """Test camera with wrong web hook.""" + wrong_url = "http://wrong-url" + + client = create_mock_motioneye_client() + cameras = copy.deepcopy(TEST_CAMERAS) + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_NOTIFICATIONS_URL] = wrong_url + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_STORAGE_URL] = wrong_url + client.async_get_cameras = AsyncMock(return_value=cameras) + + config_entry = create_mock_motioneye_config_entry(hass) + await setup_mock_motioneye_config_entry( + hass, + config_entry=config_entry, + client=client, + ) + assert not client.async_set_camera.called + + # Update the options, which will trigger a reload with the new behavior. + with patch( + "homeassistant.components.motioneye.MotionEyeClient", + return_value=client, + ): + hass.config_entries.async_update_entry( + config_entry, options={CONF_WEBHOOK_SET_OVERWRITE: True} + ) + await hass.async_block_till_done() + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_device( + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} + ) + assert device + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" + ) + + expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True + expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" + ) + + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_setup_camera_with_old_webhook( + hass: HomeAssistant, +) -> None: + """Verify that webhooks are overwritten if they are from this integration. + + Even if the overwrite option is disabled, verify the behavior is still to + overwrite incorrect versions of the URL that were set by this integration. + + (To allow the web hook URL to be seamlessly updated in future versions) + """ + + old_url = "http://old-url?src=hass-motioneye" + + client = create_mock_motioneye_client() + cameras = copy.deepcopy(TEST_CAMERAS) + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_NOTIFICATIONS_URL] = old_url + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_STORAGE_URL] = old_url + client.async_get_cameras = AsyncMock(return_value=cameras) + + config_entry = create_mock_motioneye_config_entry(hass) + await setup_mock_motioneye_config_entry( + hass, + config_entry=config_entry, + client=client, + ) + assert client.async_set_camera.called + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_device( + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER} + ) + assert device + + expected_camera = copy.deepcopy(TEST_CAMERA) + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" + ) + + expected_camera[KEY_WEB_HOOK_STORAGE_ENABLED] = True + expected_camera[KEY_WEB_HOOK_STORAGE_HTTP_METHOD] = KEY_HTTP_METHOD_POST_JSON + expected_camera[KEY_WEB_HOOK_STORAGE_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" + ) + + assert client.async_set_camera.call_args == call(TEST_CAMERA_ID, expected_camera) + + +async def test_setup_camera_with_correct_webhook( + hass: HomeAssistant, +) -> None: + """Verify that webhooks are not overwritten if they are already correct.""" + + client = create_mock_motioneye_client() + config_entry = create_mock_motioneye_config_entry( + hass, data={CONF_URL: TEST_URL, CONF_WEBHOOK_ID: "webhook_secret_id"} + ) + + device_registry = await dr.async_get_registry(hass) + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + cameras = copy.deepcopy(TEST_CAMERAS) + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_NOTIFICATIONS_ENABLED] = True + cameras[KEY_CAMERAS][0][ + KEY_WEB_HOOK_NOTIFICATIONS_HTTP_METHOD + ] = KEY_HTTP_METHOD_POST_JSON + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_NOTIFICATIONS_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_MOTION_DETECTED_QUERY_STRING}&device_id={device.id}" + ) + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_STORAGE_ENABLED] = True + cameras[KEY_CAMERAS][0][ + KEY_WEB_HOOK_STORAGE_HTTP_METHOD + ] = KEY_HTTP_METHOD_POST_JSON + cameras[KEY_CAMERAS][0][KEY_WEB_HOOK_STORAGE_URL] = ( + "https://example.com" + + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]) + + f"?{WEB_HOOK_FILE_STORED_QUERY_STRING}&device_id={device.id}" + ) + client.async_get_cameras = AsyncMock(return_value=cameras) + + await setup_mock_motioneye_config_entry( + hass, + config_entry=config_entry, + client=client, + ) + + # Webhooks are correctly configured, so no set call should have been made. + assert not client.async_set_camera.called + + +async def test_good_query(hass: HomeAssistant, aiohttp_client: Any) -> None: + """Test good callbacks.""" + await async_setup_component(hass, "http", {"http": {}}) + + device_registry = await dr.async_get_registry(hass) + client = create_mock_motioneye_client() + config_entry = await setup_mock_motioneye_config_entry(hass, client=client) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={TEST_CAMERA_DEVICE_IDENTIFIER}, + ) + + data = { + "one": "1", + "two": "2", + ATTR_DEVICE_ID: device.id, + } + client = await aiohttp_client(hass.http.app) + + for event in (EVENT_MOTION_DETECTED, EVENT_FILE_STORED): + events = async_capture_events(hass, f"{DOMAIN}.{event}") + + resp = await client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + **data, + ATTR_EVENT_TYPE: event, + }, + ) + assert resp.status == HTTP_OK + + assert len(events) == 1 + assert events[0].data == { + "name": TEST_CAMERA_NAME, + "device_id": device.id, + ATTR_EVENT_TYPE: event, + CONF_WEBHOOK_ID: config_entry.data[CONF_WEBHOOK_ID], + **data, + } + + +async def test_bad_query_missing_parameters( + hass: HomeAssistant, aiohttp_client: Any +) -> None: + """Test a query with missing parameters.""" + await async_setup_component(hass, "http", {"http": {}}) + config_entry = await setup_mock_motioneye_config_entry(hass) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), json={} + ) + assert resp.status == HTTP_BAD_REQUEST + + +async def test_bad_query_no_such_device( + hass: HomeAssistant, aiohttp_client: Any +) -> None: + """Test a correct query with incorrect device.""" + await async_setup_component(hass, "http", {"http": {}}) + config_entry = await setup_mock_motioneye_config_entry(hass) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + json={ + ATTR_EVENT_TYPE: EVENT_MOTION_DETECTED, + ATTR_DEVICE_ID: "not-a-real-device", + }, + ) + assert resp.status == HTTP_BAD_REQUEST + + +async def test_bad_query_cannot_decode( + hass: HomeAssistant, aiohttp_client: Any +) -> None: + """Test a correct query with incorrect device.""" + await async_setup_component(hass, "http", {"http": {}}) + config_entry = await setup_mock_motioneye_config_entry(hass) + + client = await aiohttp_client(hass.http.app) + + motion_events = async_capture_events(hass, f"{DOMAIN}.{EVENT_MOTION_DETECTED}") + storage_events = async_capture_events(hass, f"{DOMAIN}.{EVENT_FILE_STORED}") + + resp = await client.post( + URL_WEBHOOK_PATH.format(webhook_id=config_entry.data[CONF_WEBHOOK_ID]), + data=b"this is not json", + ) + assert resp.status == HTTP_BAD_REQUEST + assert not motion_events + assert not storage_events diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py new file mode 100644 index 00000000000..4ae834be5da --- /dev/null +++ b/tests/components/mqtt/test_humidifier.py @@ -0,0 +1,1052 @@ +"""Test MQTT humidifiers.""" +from unittest.mock import patch + +import pytest +from voluptuous.error import MultipleInvalid + +from homeassistant.components import humidifier +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + ATTR_MODE, + DOMAIN, + SERVICE_SET_HUMIDITY, + SERVICE_SET_MODE, +) +from homeassistant.components.mqtt.humidifier import MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.setup import async_setup_component + +from .test_common import ( + help_test_availability_when_connection_lost, + help_test_availability_without_topic, + help_test_custom_availability_payload, + help_test_default_availability_payload, + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_discovery_update_unchanged, + help_test_entity_debug_info_message, + help_test_entity_device_info_remove, + help_test_entity_device_info_update, + help_test_entity_device_info_with_connection, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, + help_test_setting_attribute_via_mqtt_json_message, + help_test_setting_attribute_with_template, + help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + +from tests.common import async_fire_mqtt_message + +DEFAULT_CONFIG = { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + } +} + + +async def async_turn_on( + hass, + entity_id=ENTITY_MATCH_ALL, +) -> None: + """Turn all or specified humidifier on.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data, blocking=True) + + +async def async_turn_off(hass, entity_id=ENTITY_MATCH_ALL) -> None: + """Turn all or specified humidier off.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data, blocking=True) + + +async def async_set_mode(hass, entity_id=ENTITY_MATCH_ALL, mode: str = None) -> None: + """Set mode for all or specified humidifier.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_MODE, mode)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_MODE, data, blocking=True) + + +async def async_set_humidity( + hass, entity_id=ENTITY_MATCH_ALL, humidity: int = None +) -> None: + """Set target humidity for all or specified humidifier.""" + data = { + key: value + for key, value in [(ATTR_ENTITY_ID, entity_id), (ATTR_HUMIDITY, humidity)] + if value is not None + } + + await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) + + +async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): + """Test if command fails with command topic.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + {humidifier.DOMAIN: {"platform": "mqtt", "name": "test"}}, + ) + await hass.async_block_till_done() + assert hass.states.get("humidifier.test") is None + + +async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): + """Test the controlling state via topic.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "payload_off": "StAtE_OfF", + "payload_on": "StAtE_On", + "target_humidity_state_topic": "humidity-state-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_state_topic": "mode-state-topic", + "mode_command_topic": "mode-command-topic", + "modes": [ + "auto", + "comfort", + "home", + "eco", + "sleep", + "baby", + ], + "payload_reset_humidity": "rEset_humidity", + "payload_reset_mode": "rEset_mode", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", "StAtE_On") + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "state-topic", "StAtE_OfF") + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "humidity-state-topic", "0") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 0 + + async_fire_mqtt_message(hass, "humidity-state-topic", "25") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 25 + + async_fire_mqtt_message(hass, "humidity-state-topic", "50") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 50 + + async_fire_mqtt_message(hass, "humidity-state-topic", "100") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 100 + + async_fire_mqtt_message(hass, "humidity-state-topic", "101") + assert "not a valid target humidity" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "humidity-state-topic", "invalid") + assert "not a valid target humidity" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "mode-state-topic", "low") + assert "not a valid mode" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "mode-state-topic", "auto") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + + async_fire_mqtt_message(hass, "mode-state-topic", "eco") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "eco" + + async_fire_mqtt_message(hass, "mode-state-topic", "baby") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "baby" + + async_fire_mqtt_message(hass, "mode-state-topic", "ModeUnknown") + assert "not a valid mode" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "mode-state-topic", "rEset_mode") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) is None + + async_fire_mqtt_message(hass, "humidity-state-topic", "rEset_humidity") + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) is None + + +async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): + """Test the controlling state via topic and JSON message.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "target_humidity_state_topic": "humidity-state-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_state_topic": "mode-state-topic", + "mode_command_topic": "mode-command-topic", + "modes": [ + "auto", + "eco", + "baby", + ], + "state_value_template": "{{ value_json.val }}", + "target_humidity_state_template": "{{ value_json.val }}", + "mode_state_template": "{{ value_json.val }}", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "state-topic", '{"val":"ON"}') + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "state-topic", '{"val":"OFF"}') + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + + async_fire_mqtt_message(hass, "humidity-state-topic", '{"val": 1}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 1 + + async_fire_mqtt_message(hass, "humidity-state-topic", '{"val": 100}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 100 + + async_fire_mqtt_message(hass, "humidity-state-topic", '{"val": "None"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) is None + + async_fire_mqtt_message(hass, "humidity-state-topic", '{"otherval": 100}') + assert "Ignoring empty target humidity from" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "low"}') + assert "not a valid mode" in caplog.text + caplog.clear() + + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "auto"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "eco"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "eco" + + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "baby"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "baby" + + async_fire_mqtt_message(hass, "mode-state-topic", '{"val": "None"}') + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) is None + + async_fire_mqtt_message(hass, "mode-state-topic", '{"otherval": 100}') + assert "Ignoring empty mode from" in caplog.text + caplog.clear() + + +async def test_controlling_state_via_topic_and_json_message_shared_topic( + hass, mqtt_mock, caplog +): + """Test the controlling state via topic and JSON message using a shared topic.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "shared-state-topic", + "command_topic": "command-topic", + "target_humidity_state_topic": "shared-state-topic", + "target_humidity_command_topic": "percentage-command-topic", + "mode_state_topic": "shared-state-topic", + "mode_command_topic": "mode-command-topic", + "modes": [ + "auto", + "eco", + "baby", + ], + "state_value_template": "{{ value_json.state }}", + "target_humidity_state_template": "{{ value_json.humidity }}", + "mode_state_template": "{{ value_json.mode }}", + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message( + hass, + "shared-state-topic", + '{"state":"ON","mode":"eco","humidity": 50}', + ) + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 50 + assert state.attributes.get(humidifier.ATTR_MODE) == "eco" + + async_fire_mqtt_message( + hass, + "shared-state-topic", + '{"state":"ON","mode":"auto","humidity": 10}', + ) + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 10 + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + + async_fire_mqtt_message( + hass, + "shared-state-topic", + '{"state":"OFF","mode":"auto","humidity": 0}', + ) + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 0 + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + + async_fire_mqtt_message( + hass, + "shared-state-topic", + '{"humidity": 100}', + ) + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 100 + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + assert "Ignoring empty mode from" in caplog.text + assert "Ignoring empty state from" in caplog.text + caplog.clear() + + +async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): + """Test optimistic mode without state topic.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "payload_off": "StAtE_OfF", + "payload_on": "StAtE_On", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": [ + "eco", + "auto", + "baby", + ], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_on(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "StAtE_On", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_off(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "StAtE_OfF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(MultipleInvalid): + await async_set_humidity(hass, "humidifier.test", -1) + + with pytest.raises(MultipleInvalid): + await async_set_humidity(hass, "humidifier.test", 101) + + await async_set_humidity(hass, "humidifier.test", 100) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "100", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 100 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 0) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 0 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "low") + assert "not a valid mode" in caplog.text + caplog.clear() + + await async_set_mode(hass, "humidifier.test", "auto") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "auto", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "eco") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "eco", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "eco" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): + """Testing command templates with optimistic mode without state topic.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "command_template": "state: {{ value }}", + "target_humidity_command_topic": "humidity-command-topic", + "target_humidity_command_template": "humidity: {{ value }}", + "mode_command_topic": "mode-command-topic", + "mode_command_template": "mode: {{ value }}", + "modes": [ + "auto", + "eco", + "sleep", + ], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_on(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "state: ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_off(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with( + "command-topic", "state: OFF", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(MultipleInvalid): + await async_set_humidity(hass, "humidifier.test", -1) + + with pytest.raises(MultipleInvalid): + await async_set_humidity(hass, "humidifier.test", 101) + + await async_set_humidity(hass, "humidifier.test", 100) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "humidity: 100", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 100 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 0) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "humidity: 0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_HUMIDITY) == 0 + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "low") + assert "not a valid mode" in caplog.text + caplog.clear() + + await async_set_mode(hass, "humidifier.test", "eco") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "mode: eco", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "eco" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "auto") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "mode: auto", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.attributes.get(humidifier.ATTR_MODE) == "auto" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): + """Test optimistic mode with state topic and turn on attributes.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "state-topic", + "command_topic": "command-topic", + "target_humidity_state_topic": "humidity-state-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "mode_state_topic": "mode-state-topic", + "modes": [ + "auto", + "eco", + "baby", + ], + "optimistic": True, + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_on(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "ON", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_turn_off(hass, "humidifier.test") + mqtt_mock.async_publish.assert_called_once_with("command-topic", "OFF", 0, False) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 33) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "33", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 50) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "50", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 100) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "100", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_humidity(hass, "humidifier.test", 0) + mqtt_mock.async_publish.assert_called_once_with( + "humidity-command-topic", "0", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + with pytest.raises(MultipleInvalid): + await async_set_humidity(hass, "humidifier.test", 101) + + await async_set_mode(hass, "humidifier.test", "low") + assert "not a valid mode" in caplog.text + caplog.clear() + + await async_set_mode(hass, "humidifier.test", "eco") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "eco", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "baby") + mqtt_mock.async_publish.assert_called_once_with( + "mode-command-topic", "baby", 0, False + ) + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + await async_set_mode(hass, "humidifier.test", "freaking-high") + assert "not a valid mode" in caplog.text + caplog.clear() + + mqtt_mock.async_publish.reset_mock() + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + + +async def test_attributes(hass, mqtt_mock, caplog): + """Test attributes.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "command-topic", + "mode_command_topic": "mode-command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "modes": [ + "eco", + "baby", + ], + } + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(humidifier.ATTR_AVAILABLE_MODES) == [ + "eco", + "baby", + ] + assert state.attributes.get(humidifier.ATTR_MIN_HUMIDITY) == 0 + assert state.attributes.get(humidifier.ATTR_MAX_HUMIDITY) == 100 + + await async_turn_on(hass, "humidifier.test") + state = hass.states.get("humidifier.test") + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(humidifier.ATTR_HUMIDITY) is None + assert state.attributes.get(humidifier.ATTR_MODE) is None + + await async_turn_off(hass, "humidifier.test") + state = hass.states.get("humidifier.test") + assert state.state == STATE_OFF + assert state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes.get(humidifier.ATTR_HUMIDITY) is None + assert state.attributes.get(humidifier.ATTR_MODE) is None + + +async def test_invalid_configurations(hass, mqtt_mock, caplog): + """Test invalid configurations.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: [ + { + "platform": "mqtt", + "name": "test_valid_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + }, + { + "platform": "mqtt", + "name": "test_valid_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "humidifier", + }, + { + "platform": "mqtt", + "name": "test_valid_3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "dehumidifier", + }, + { + "platform": "mqtt", + "name": "test_invalid_device_class", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "device_class": "notsupporedSpeci@l", + }, + { + "platform": "mqtt", + "name": "test_mode_command_without_modes", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + }, + { + "platform": "mqtt", + "name": "test_invalid_humidity_min_max_1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "min_humidity": 0, + "max_humidity": 101, + }, + { + "platform": "mqtt", + "name": "test_invalid_humidity_min_max_2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "max_humidity": 20, + "min_humidity": 40, + }, + { + "platform": "mqtt", + "name": "test_invalid_mode_is_reset", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "None"], + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get("humidifier.test_valid_1") is not None + assert hass.states.get("humidifier.test_valid_2") is not None + assert hass.states.get("humidifier.test_valid_3") is not None + assert hass.states.get("humidifier.test_invalid_device_class") is None + assert hass.states.get("humidifier.test_mode_command_without_modes") is None + assert "not all values in the same group of inclusion" in caplog.text + caplog.clear() + + assert hass.states.get("humidifier.test_invalid_humidity_min_max_1") is None + assert hass.states.get("humidifier.test_invalid_humidity_min_max_2") is None + assert hass.states.get("humidifier.test_invalid_mode_is_reset") is None + + +async def test_supported_features(hass, mqtt_mock): + """Test supported features.""" + assert await async_setup_component( + hass, + humidifier.DOMAIN, + { + humidifier.DOMAIN: [ + { + "platform": "mqtt", + "name": "test1", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + }, + { + "platform": "mqtt", + "name": "test2", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + }, + { + "platform": "mqtt", + "name": "test3", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + }, + { + "platform": "mqtt", + "name": "test4", + "command_topic": "command-topic", + "target_humidity_command_topic": "humidity-command-topic", + "mode_command_topic": "mode-command-topic", + "modes": ["eco", "auto"], + }, + { + "platform": "mqtt", + "name": "test5", + "command_topic": "command-topic", + }, + { + "platform": "mqtt", + "name": "test6", + "target_humidity_command_topic": "humidity-command-topic", + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get("humidifier.test1") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 + + state = hass.states.get("humidifier.test2") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == humidifier.SUPPORT_MODES + + state = hass.states.get("humidifier.test3") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 + + state = hass.states.get("humidifier.test4") + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == humidifier.SUPPORT_MODES + + state = hass.states.get("humidifier.test5") + assert state is None + + state = hass.states.get("humidifier.test6") + assert state is None + + +async def test_availability_when_connection_lost(hass, mqtt_mock): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability_without_topic(hass, mqtt_mock): + """Test availability without defined availability topic.""" + await help_test_availability_without_topic( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_default_availability_payload(hass, mqtt_mock): + """Test availability by default payload with defined topic.""" + await help_test_default_availability_payload( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + ) + + +async def test_custom_availability_payload(hass, mqtt_mock): + """Test availability by custom payload with defined topic.""" + await help_test_custom_availability_payload( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + ) + + +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_blocked_attribute_via_mqtt_json_message( + hass, + mqtt_mock, + humidifier.DOMAIN, + DEFAULT_CONFIG, + MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED, + ) + + +async def test_setting_attribute_with_template(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_with_template( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): + """Test attributes get extracted from a JSON result.""" + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_update_attr(hass, mqtt_mock, caplog): + """Test update of discovered MQTTAttributes.""" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_unique_id(hass, mqtt_mock): + """Test unique_id option only creates one fan per id.""" + config = { + humidifier.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "target_humidity_command_topic": "humidity-command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, mqtt_mock, humidifier.DOMAIN, config) + + +async def test_discovery_removal_humidifier(hass, mqtt_mock, caplog): + """Test removal of discovered humidifier.""" + data = '{ "name": "test", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, humidifier.DOMAIN, data) + + +async def test_discovery_update_humidifier(hass, mqtt_mock, caplog): + """Test update of discovered humidifier.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' + await help_test_discovery_update( + hass, mqtt_mock, caplog, humidifier.DOMAIN, data1, data2 + ) + + +async def test_discovery_update_unchanged_humidifier(hass, mqtt_mock, caplog): + """Test update of discovered humidifier.""" + data1 = '{ "name": "Beer", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' + with patch( + "homeassistant.components.mqtt.fan.MqttFan.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, humidifier.DOMAIN, data1, discovery_update + ) + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, humidifier.DOMAIN, data1, data2 + ) + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT fan device registry integration.""" + await help_test_entity_device_info_with_connection( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT fan device registry integration.""" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + await help_test_entity_device_info_update( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_device_info_remove(hass, mqtt_mock): + """Test device registry remove.""" + await help_test_entity_device_info_remove( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_debug_info_message(hass, mqtt_mock): + """Test MQTT debug info.""" + await help_test_entity_debug_info_message( + hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 5dad989a5cf..f2e48e10dc5 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -15,7 +15,7 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -122,6 +122,13 @@ async def test_value_template(hass, mqtt_mock): state = hass.states.get("select.test_select") assert state.state == "beer" + async_fire_mqtt_message(hass, topic, '{"val": null}') + + await hass.async_block_till_done() + + state = hass.states.get("select.test_select") + assert state.state == STATE_UNKNOWN + async def test_run_select_service_optimistic(hass, mqtt_mock): """Test that set_value service works in optimistic mode.""" diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 8fbe9486352..49c32301442 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -156,3 +156,17 @@ def gps_sensor(gateway_nodes, gps_sensor_state) -> Sensor: nodes = update_gateway_nodes(gateway_nodes, gps_sensor_state) node = nodes[1] return node + + +@pytest.fixture(name="power_sensor_state", scope="session") +def power_sensor_state_fixture() -> dict: + """Load the power sensor state.""" + return load_nodes_state("mysensors/power_sensor_state.json") + + +@pytest.fixture +def power_sensor(gateway_nodes, power_sensor_state) -> Sensor: + """Load the power sensor.""" + nodes = update_gateway_nodes(gateway_nodes, power_sensor_state) + node = nodes[1] + return node diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 69caeac9977..6edddc68592 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -1,6 +1,15 @@ """Provide tests for mysensors sensor platform.""" +from homeassistant.components.sensor import ATTR_STATE_CLASS, STATE_CLASS_MEASUREMENT +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_POWER, + POWER_WATT, +) + + async def test_gps_sensor(hass, gps_sensor, integration): """Test a gps sensor.""" entity_id = "sensor.gps_sensor_1_1" @@ -8,3 +17,15 @@ async def test_gps_sensor(hass, gps_sensor, integration): state = hass.states.get(entity_id) assert state.state == "40.741894,-73.989311,12" + + +async def test_power_sensor(hass, power_sensor, integration): + """Test a power sensor.""" + entity_id = "sensor.power_sensor_1_1" + + state = hass.states.get(entity_id) + + assert state.state == "1200" + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_POWER + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT + assert state.attributes[ATTR_STATE_CLASS] == STATE_CLASS_MEASUREMENT diff --git a/tests/components/nam/test_sensor.py b/tests/components/nam/test_sensor.py index 506a81f7619..c5850ce719d 100644 --- a/tests/components/nam/test_sensor.py +++ b/tests/components/nam/test_sensor.py @@ -441,8 +441,6 @@ async def test_unique_id_migration(hass): assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_temperature" - await init_integration(hass) - entry = registry.async_get("sensor.nettigo_air_monitor_dht22_humidity") assert entry assert entry.unique_id == "aa:bb:cc:dd:ee:ff-dht22_humidity" diff --git a/tests/components/nest/device_info_test.py b/tests/components/nest/device_info_test.py index 1561364d348..a0c6973c1d6 100644 --- a/tests/components/nest/device_info_test.py +++ b/tests/components/nest/device_info_test.py @@ -2,7 +2,7 @@ from google_nest_sdm.device import Device -from homeassistant.components.nest.device_info import DeviceInfo +from homeassistant.components.nest.device_info import NestDeviceInfo def test_device_custom_name(): @@ -20,7 +20,7 @@ def test_device_custom_name(): auth=None, ) - device_info = DeviceInfo(device) + device_info = NestDeviceInfo(device) assert device_info.device_name == "My Doorbell" assert device_info.device_model == "Doorbell" assert device_info.device_brand == "Google Nest" @@ -45,7 +45,7 @@ def test_device_name_room(): auth=None, ) - device_info = DeviceInfo(device) + device_info = NestDeviceInfo(device) assert device_info.device_name == "Some Room" assert device_info.device_model == "Doorbell" assert device_info.device_brand == "Google Nest" @@ -64,7 +64,7 @@ def test_device_no_name(): auth=None, ) - device_info = DeviceInfo(device) + device_info = NestDeviceInfo(device) assert device_info.device_name == "Doorbell" assert device_info.device_model == "Doorbell" assert device_info.device_brand == "Google Nest" @@ -91,13 +91,13 @@ def test_device_invalid_type(): auth=None, ) - device_info = DeviceInfo(device) + device_info = NestDeviceInfo(device) assert device_info.device_name == "My Doorbell" - assert device_info.device_model is None + assert device_info.device_model == "Unknown" assert device_info.device_brand == "Google Nest" assert device_info.device_info == { "identifiers": {("nest", "some-device-id")}, "name": "My Doorbell", "manufacturer": "Google Nest", - "model": None, + "model": "Unknown", } diff --git a/tests/components/nest/sensor_sdm_test.py b/tests/components/nest/sensor_sdm_test.py index dfdfd58d546..cc18e8cd3ae 100644 --- a/tests/components/nest/sensor_sdm_test.py +++ b/tests/components/nest/sensor_sdm_test.py @@ -208,5 +208,5 @@ async def test_device_with_unknown_type(hass): device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "My Sensor" - assert device.model is None + assert device.model == "Unknown" assert device.identifiers == {("nest", "some-device-id")} diff --git a/tests/components/netatmo/test_select.py b/tests/components/netatmo/test_select.py new file mode 100644 index 00000000000..8be010cc802 --- /dev/null +++ b/tests/components/netatmo/test_select.py @@ -0,0 +1,65 @@ +"""The tests for the Netatmo climate platform.""" +from unittest.mock import patch + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS +from homeassistant.const import ATTR_ENTITY_ID, CONF_WEBHOOK_ID, SERVICE_SELECT_OPTION + +from .common import selected_platforms, simulate_webhook + + +async def test_select_schedule_thermostats(hass, config_entry, caplog, netatmo_auth): + """Test service for selecting Netatmo schedule with thermostats.""" + with selected_platforms(["climate", "select"]): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + select_entity = "select.netatmo_myhome" + + assert hass.states.get(select_entity).state == "Default" + assert hass.states.get(select_entity).attributes[ATTR_OPTIONS] == [ + "Default", + "Winter", + ] + + # Fake backend response changing schedule + response = { + "event_type": "schedule", + "schedule_id": "b1b54a2f45795764f59d50d8", + "previous_schedule_id": "59d32176d183948b05ab4dce", + "push_type": "home_event_changed", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(select_entity).state == "Winter" + + # Test setting a different schedule + with patch( + "pyatmo.thermostat.AsyncHomeData.async_switch_home_schedule" + ) as mock_switch_home_schedule: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: select_entity, + ATTR_OPTION: "Default", + }, + blocking=True, + ) + await hass.async_block_till_done() + mock_switch_home_schedule.assert_called_once_with( + home_id="91763b24c43d3e344f424e8b", schedule_id="591b54a2764ff4d50d8b5795" + ) + + # Fake backend response changing schedule + response = { + "event_type": "schedule", + "schedule_id": "591b54a2764ff4d50d8b5795", + "previous_schedule_id": "b1b54a2f45795764f59d50d8", + "push_type": "home_event_changed", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(select_entity).state == "Default" diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index 41d87d5a805..bc4c543842f 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -7,6 +7,7 @@ from homeassistant.components import network from homeassistant.components.network.const import ( ATTR_ADAPTERS, ATTR_CONFIGURED_ADAPTERS, + MDNS_TARGET_IP, STORAGE_KEY, STORAGE_VERSION, ) @@ -444,3 +445,66 @@ async def test_interfaces_configured_from_storage_websocket_update( "name": "vtun0", }, ] + + +async def test_async_get_source_ip_matching_interface(hass, hass_storage): + """Test getting the source ip address with interface matching.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=["192.168.1.5"], + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" + + +async def test_async_get_source_ip_interface_not_match(hass, hass_storage): + """Test getting the source ip address with interface does not match.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["vtun0"]}, + } + + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=["192.168.1.5"], + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "169.254.3.2" + + +async def test_async_get_source_ip_cannot_determine_target(hass, hass_storage): + """Test getting the source ip address when getsockname fails.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth1"]}, + } + + with patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[None], + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + assert await network.async_get_source_ip(hass, MDNS_TARGET_IP) == "192.168.1.5" diff --git a/tests/components/nfandroidtv/__init__.py b/tests/components/nfandroidtv/__init__.py new file mode 100644 index 00000000000..056e2b2bc71 --- /dev/null +++ b/tests/components/nfandroidtv/__init__.py @@ -0,0 +1,31 @@ +"""Tests for the NFAndroidTV integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_HOST, CONF_NAME + +HOST = "1.2.3.4" +NAME = "Android TV / Fire TV" + +CONF_DATA = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + +CONF_CONFIG_FLOW = { + CONF_HOST: HOST, + CONF_NAME: NAME, +} + + +async def _create_mocked_tv(raise_exception=False): + mocked_tv = AsyncMock() + mocked_tv.get_state = AsyncMock() + return mocked_tv + + +def _patch_config_flow_tv(mocked_tv): + return patch( + "homeassistant.components.nfandroidtv.config_flow.Notifications", + return_value=mocked_tv, + ) diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py new file mode 100644 index 00000000000..b16b053c70f --- /dev/null +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test NFAndroidTV config flow.""" +from unittest.mock import patch + +from notifications_android_tv.notifications import ConnectError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.nfandroidtv.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME + +from . import ( + CONF_CONFIG_FLOW, + CONF_DATA, + HOST, + NAME, + _create_mocked_tv, + _patch_config_flow_tv, +) + +from tests.common import MockConfigEntry + + +def _patch_setup(): + return patch( + "homeassistant.components.nfandroidtv.async_setup_entry", + return_value=True, + ) + + +async def test_flow_user(hass): + """Test user initialized flow.""" + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + 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_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"] == CONF_DATA + + +async def test_flow_user_already_configured(hass): + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + unique_id=HOST, + ) + + entry.add_to_hass(hass) + + mocked_tv = await _create_mocked_tv() + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + 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_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = ConnectError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_user_unknown_error(hass): + """Test user initialized flow with unreachable server.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv) as tvmock: + tvmock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=CONF_CONFIG_FLOW, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass): + """Test an import flow.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == CONF_DATA + + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=CONF_CONFIG_FLOW, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_import_missing_optional(hass): + """Test an import flow with missing options.""" + mocked_tv = await _create_mocked_tv(True) + with _patch_config_flow_tv(mocked_tv), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: HOST}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == {CONF_HOST: HOST, CONF_NAME: f"{DEFAULT_NAME} {HOST}"} diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py index 44b181b1ec4..aa5ca3bf66c 100644 --- a/tests/components/nws/test_sensor.py +++ b/tests/components/nws/test_sensor.py @@ -1,12 +1,7 @@ """Sensors for National Weather Service (NWS).""" import pytest -from homeassistant.components.nws.const import ( - ATTR_LABEL, - ATTRIBUTION, - DOMAIN, - SENSOR_TYPES, -) +from homeassistant.components.nws.const import ATTRIBUTION, DOMAIN, SENSOR_TYPES from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN from homeassistant.util import slugify @@ -40,12 +35,12 @@ async def test_imperial_metric( """Test with imperial and metric units.""" registry = await hass.helpers.entity_registry.async_get_registry() - for sensor_name, sensor_data in SENSOR_TYPES.items(): + for description in SENSOR_TYPES: registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, - f"35_-75_{sensor_name}", - suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + f"35_-75_{description.key}", + suggested_object_id=f"abc_{description.name}", disabled_by=None, ) @@ -58,10 +53,11 @@ async def test_imperial_metric( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for sensor_name, sensor_data in SENSOR_TYPES.items(): - state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + for description in SENSOR_TYPES: + assert description.name + state = hass.states.get(f"sensor.abc_{slugify(description.name)}") assert state - assert state.state == result_observation[sensor_name] + assert state.state == result_observation[description.key] assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION @@ -72,12 +68,12 @@ async def test_none_values(hass, mock_simple_nws, no_weather): registry = await hass.helpers.entity_registry.async_get_registry() - for sensor_name, sensor_data in SENSOR_TYPES.items(): + for description in SENSOR_TYPES: registry.async_get_or_create( SENSOR_DOMAIN, DOMAIN, - f"35_-75_{sensor_name}", - suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + f"35_-75_{description.key}", + suggested_object_id=f"abc_{description.name}", disabled_by=None, ) @@ -89,7 +85,8 @@ async def test_none_values(hass, mock_simple_nws, no_weather): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - for sensor_name, sensor_data in SENSOR_TYPES.items(): - state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + for description in SENSOR_TYPES: + assert description.name + state = hass.states.get(f"sensor.abc_{slugify(description.name)}") assert state assert state.state == STATE_UNKNOWN diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index a921dfe39d4..75fcb9c0746 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -187,7 +187,7 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): # Validate created areas area_registry = ar.async_get(hass) assert len(area_registry.areas) == 3 - assert sorted([area.name for area in area_registry.async_list_areas()]) == [ + assert sorted(area.name for area in area_registry.async_list_areas()) == [ "Bedroom", "Kitchen", "Living Room", diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index 1eb2b4b390a..5c12571fc1e 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -14,14 +14,14 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_VOLTAGE, - ELECTRICAL_CURRENT_AMPERE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, LIGHT_LUX, PERCENTAGE, PRESSURE_MBAR, STATE_OFF, STATE_ON, TEMP_CELSIUS, - VOLT, ) MOCK_OWPROXY_DEVICES = { @@ -347,7 +347,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/VAD", "injected_value": b" 2.97", "result": "3.0", - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, "class": DEVICE_CLASS_VOLTAGE, "disabled": True, }, @@ -356,7 +356,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/VDD", "injected_value": b" 4.74", "result": "4.7", - "unit": VOLT, + "unit": ELECTRIC_POTENTIAL_VOLT, "class": DEVICE_CLASS_VOLTAGE, "disabled": True, }, @@ -365,7 +365,7 @@ MOCK_OWPROXY_DEVICES = { "unique_id": "/26.111111111111/IAD", "injected_value": b" 1", "result": "1.0", - "unit": ELECTRICAL_CURRENT_AMPERE, + "unit": ELECTRIC_CURRENT_AMPERE, "class": DEVICE_CLASS_CURRENT, "disabled": True, }, diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py index 83626c2d9f6..3feeb2638b4 100644 --- a/tests/components/openuv/test_config_flow.py +++ b/tests/components/openuv/test_config_flow.py @@ -2,7 +2,6 @@ from unittest.mock import patch from pyopenuv.errors import InvalidApiKeyError -import pytest from homeassistant import data_entry_flow from homeassistant.components.openuv import DOMAIN @@ -17,19 +16,6 @@ from homeassistant.const import ( from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_setup(): - """Prevent setup.""" - with patch( - "homeassistant.components.openuv.async_setup", - return_value=True, - ), patch( - "homeassistant.components.openuv.async_setup_entry", - return_value=True, - ): - yield - - async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = { @@ -81,7 +67,7 @@ async def test_step_user(hass): } with patch( - "homeassistant.components.airvisual.async_setup_entry", return_value=True + "homeassistant.components.openuv.async_setup_entry", return_value=True ), patch("pyopenuv.client.Client.uv_index"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 716864c1cb1..72958fc10c0 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -624,7 +624,9 @@ async def test_manual_config(hass, mock_plex_calls, current_request_with_host): assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN -async def test_manual_config_with_token(hass, mock_plex_calls): +async def test_manual_config_with_token( + hass, mock_plex_calls, requests_mock, empty_library, empty_payload +): """Test creating via manual configuration with only token.""" result = await hass.config_entries.flow.async_init( @@ -653,13 +655,19 @@ async def test_manual_config_with_token(hass, mock_plex_calls): server_id = result["data"][CONF_SERVER_IDENTIFIER] mock_plex_server = hass.data[DOMAIN][SERVERS][server_id] + mock_url = mock_plex_server.url_in_use - assert result["title"] == mock_plex_server.url_in_use + assert result["title"] == mock_url assert result["data"][CONF_SERVER] == mock_plex_server.friendly_name assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machine_identifier - assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server.url_in_use + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_url assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + # Complete Plex integration setup before teardown + requests_mock.get(f"{mock_url}/library", text=empty_library) + requests_mock.get(f"{mock_url}/library/sections", text=empty_payload) + await hass.async_block_till_done() + async def test_setup_with_limited_credentials(hass, entry, setup_plex_server): """Test setup with a user with limited permissions.""" diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index 530f265f3f0..081adb845f4 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -177,6 +177,7 @@ async def test_setup_with_unknown_session(hass, entry, setup_plex_server): async def test_setup_when_certificate_changed( hass, requests_mock, + empty_library, empty_payload, plex_server_accounts, plex_server_default, @@ -210,13 +211,10 @@ async def test_setup_when_certificate_changed( requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) - - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) requests_mock.get(old_url, exc=WrongCertHostnameException) # Test with account failure - requests_mock.get(f"{old_url}/accounts", status_code=401) + requests_mock.get("https://plex.tv/users/account", status_code=401) old_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(old_entry.entry_id) is False await hass.async_block_till_done() @@ -225,7 +223,7 @@ async def test_setup_when_certificate_changed( await hass.config_entries.async_unload(old_entry.entry_id) # Test with no servers found - requests_mock.get(f"{old_url}/accounts", text=plex_server_accounts) + requests_mock.get("https://plex.tv/users/account", text=plextv_account) requests_mock.get("https://plex.tv/api/resources", text=empty_payload) assert await hass.config_entries.async_setup(old_entry.entry_id) is False @@ -237,8 +235,11 @@ async def test_setup_when_certificate_changed( # Test with success new_url = PLEX_DIRECT_URL requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) - requests_mock.get(new_url, text=plex_server_default) + 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) + requests_mock.get(f"{new_url}/library/sections", text=empty_payload) assert await hass.config_entries.async_setup(old_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 1697a43127e..04c42e0ba83 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Plugwise config flow.""" -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from plugwise.exceptions import ( ConnectionFailedError, @@ -8,11 +8,15 @@ from plugwise.exceptions import ( ) import pytest -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import setup from homeassistant.components.plugwise.const import ( + API, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, + FLOW_NET, + FLOW_TYPE, + PW_TYPE, ) from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import ( @@ -21,13 +25,16 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, + CONF_SOURCE, CONF_USERNAME, ) +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM from tests.common import MockConfigEntry TEST_HOST = "1.1.1.1" TEST_HOSTNAME = "smileabcdef" +TEST_HOSTNAME2 = "stretchabc" TEST_PASSWORD = "test_password" TEST_PORT = 81 TEST_USERNAME = "smile" @@ -44,6 +51,17 @@ TEST_DISCOVERY = { "hostname": f"{TEST_HOSTNAME}.local.", }, } +TEST_DISCOVERY2 = { + "host": TEST_HOST, + "port": DEFAULT_PORT, + "hostname": f"{TEST_HOSTNAME2}.local.", + "server": f"{TEST_HOSTNAME2}.local.", + "properties": { + "product": "stretch", + "version": "1.2.3", + "hostname": f"{TEST_HOSTNAME2}.local.", + }, +} @pytest.fixture(name="mock_smile") @@ -52,20 +70,38 @@ def mock_smile(): with patch( "homeassistant.components.plugwise.config_flow.Smile", ) as smile_mock: - smile_mock.PlugwiseError = PlugwiseException + smile_mock.PlugwiseException = PlugwiseException smile_mock.InvalidAuthentication = InvalidAuthentication smile_mock.ConnectionFailedError = ConnectionFailedError smile_mock.return_value.connect.return_value = True yield smile_mock.return_value +async def test_form_flow_gateway(hass): + """Test we get the form for Plugwise Gateway product type.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={FLOW_TYPE: FLOW_NET} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + assert result["step_id"] == "user_gateway" + + async def test_form(hass): """Test we get the form.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} with patch( @@ -77,17 +113,18 @@ async def test_form(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["data"] == { CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: DEFAULT_PORT, CONF_USERNAME: TEST_USERNAME, + PW_TYPE: API, } assert len(mock_setup_entry.mock_calls) == 1 @@ -98,10 +135,10 @@ async def test_zeroconf_form(hass): await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_ZEROCONF}, + context={CONF_SOURCE: SOURCE_ZEROCONF}, data=TEST_DISCOVERY, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} with patch( @@ -113,17 +150,55 @@ async def test_zeroconf_form(hass): ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_PASSWORD: TEST_PASSWORD}, ) await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["data"] == { CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: DEFAULT_PORT, CONF_USERNAME: TEST_USERNAME, + PW_TYPE: API, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_stretch_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_ZEROCONF}, + data=TEST_DISCOVERY2, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.plugwise.config_flow.Smile.connect", + return_value=True, + ), patch( + "homeassistant.components.plugwise.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PASSWORD: TEST_PASSWORD}, + ) + + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: TEST_HOST, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: TEST_USERNAME2, + PW_TYPE: API, } assert len(mock_setup_entry.mock_calls) == 1 @@ -131,58 +206,68 @@ async def test_zeroconf_form(hass): async def test_form_username(hass): """Test we get the username data back.""" + await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} with patch( - "homeassistant.components.plugwise.config_flow.Smile.connect", - return_value=True, - ), patch( + "homeassistant.components.plugwise.config_flow.Smile", + ) as smile_mock, patch( "homeassistant.components.plugwise.async_setup_entry", return_value=True, ) as mock_setup_entry: + smile_mock.return_value.connect.side_effect = AsyncMock(return_value=True) + smile_mock.return_value.gateway_id = "abcdefgh12345678" + smile_mock.return_value.smile_hostname = TEST_HOST + smile_mock.return_value.smile_name = "Adam" + result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { + user_input={ CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD, CONF_USERNAME: TEST_USERNAME2, }, ) - await hass.async_block_till_done() + await hass.async_block_till_done() - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["data"] == { - CONF_HOST: TEST_HOST, - CONF_PASSWORD: TEST_PASSWORD, - CONF_PORT: DEFAULT_PORT, - CONF_USERNAME: TEST_USERNAME2, - } + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + CONF_HOST: TEST_HOST, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: DEFAULT_PORT, + CONF_USERNAME: TEST_USERNAME2, + PW_TYPE: API, + } assert len(mock_setup_entry.mock_calls) == 1 result3 = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": SOURCE_ZEROCONF}, + context={CONF_SOURCE: SOURCE_ZEROCONF}, data=TEST_DISCOVERY, ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["errors"] == {} + assert result3["type"] == RESULT_TYPE_FORM with patch( - "homeassistant.components.plugwise.config_flow.Smile.connect", - return_value=True, - ), patch( + "homeassistant.components.plugwise.config_flow.Smile", + ) as smile_mock, patch( "homeassistant.components.plugwise.async_setup_entry", return_value=True, ) as mock_setup_entry: + smile_mock.return_value.side_effect = AsyncMock(return_value=True) + smile_mock.return_value.connect.side_effect = AsyncMock(return_value=True) + smile_mock.return_value.gateway_id = "abcdefgh12345678" + smile_mock.return_value.smile_hostname = TEST_HOST + smile_mock.return_value.smile_name = "Adam" + result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], - {CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_PASSWORD: TEST_PASSWORD}, ) await hass.async_block_till_done() @@ -194,7 +279,7 @@ async def test_form_username(hass): async def test_form_invalid_auth(hass, mock_smile): """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) mock_smile.connect.side_effect = InvalidAuthentication @@ -202,17 +287,17 @@ async def test_form_invalid_auth(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass, mock_smile): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) mock_smile.connect.side_effect = ConnectionFailedError @@ -220,17 +305,17 @@ async def test_form_cannot_connect(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} async def test_form_cannot_connect_port(hass, mock_smile): """Test we handle cannot connect to port error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) mock_smile.connect.side_effect = ConnectionFailedError @@ -238,17 +323,21 @@ async def test_form_cannot_connect_port(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD, CONF_PORT: TEST_PORT}, + user_input={ + CONF_HOST: TEST_HOST, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + }, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "cannot_connect"} async def test_form_other_problem(hass, mock_smile): """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data={FLOW_TYPE: FLOW_NET} ) mock_smile.connect.side_effect = TimeoutError @@ -256,10 +345,10 @@ async def test_form_other_problem(hass, mock_smile): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, + user_input={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "unknown"} @@ -283,13 +372,13 @@ async def test_options_flow_power(hass, mock_smile) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SCAN_INTERVAL: 10} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["data"] == { CONF_SCAN_INTERVAL: 10, } @@ -315,14 +404,14 @@ async def test_options_flow_thermo(hass, mock_smile) -> None: result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SCAN_INTERVAL: 60} ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert result["data"] == { CONF_SCAN_INTERVAL: 60, } diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index b1abe1de3e5..f8fcdd4561a 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -31,9 +31,12 @@ class FilterTest: should_pass: bool -async def prometheus_client(hass, hass_client): +async def prometheus_client(hass, hass_client, namespace): """Initialize an hass_client with Prometheus component.""" - await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + config = {} + if namespace is not None: + config[prometheus.CONF_PROM_NAMESPACE] = namespace + await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: config}) await async_setup_component(hass, sensor.DOMAIN, {"sensor": [{"platform": "demo"}]}) @@ -98,9 +101,9 @@ async def prometheus_client(hass, hass_client): return await hass_client() -async def test_view(hass, hass_client): +async def test_view_empty_namespace(hass, hass_client): """Test prometheus metrics view.""" - client = await prometheus_client(hass, hass_client) + client = await prometheus_client(hass, hass_client, "") resp = await client.get(prometheus.API_ENDPOINT) assert resp.status == 200 @@ -117,7 +120,7 @@ async def test_view(hass, hass_client): ) assert ( - 'temperature_c{domain="sensor",' + 'sensor_temperature_celsius{domain="sensor",' 'entity="sensor.outside_temperature",' 'friendly_name="Outside Temperature"} 15.6' in body ) @@ -129,7 +132,7 @@ async def test_view(hass, hass_client): ) assert ( - 'current_temperature_c{domain="climate",' + 'climate_current_temperature_celsius{domain="climate",' 'entity="climate.heatpump",' 'friendly_name="HeatPump"} 25.0' in body ) @@ -160,7 +163,7 @@ async def test_view(hass, hass_client): ) assert ( - 'humidity_percent{domain="sensor",' + 'sensor_humidity_percent{domain="sensor",' 'entity="sensor.outside_humidity",' 'friendly_name="Outside Humidity"} 54.0' in body ) @@ -172,7 +175,7 @@ async def test_view(hass, hass_client): ) assert ( - 'power_kwh{domain="sensor",' + 'sensor_power_kwh{domain="sensor",' 'entity="sensor.radio_energy",' 'friendly_name="Radio Energy"} 14.0' in body ) @@ -208,6 +211,31 @@ async def test_view(hass, hass_client): ) +async def test_view_default_namespace(hass, hass_client): + """Test prometheus metrics view.""" + client = await prometheus_client(hass, hass_client, None) + resp = await client.get(prometheus.API_ENDPOINT) + + assert resp.status == 200 + assert resp.headers["content-type"] == CONTENT_TYPE_TEXT_PLAIN + body = await resp.text() + body = body.split("\n") + + assert len(body) > 3 + + assert "# HELP python_info Python platform information" in body + assert ( + "# HELP python_gc_objects_collected_total " + "Objects collected during gc" in body + ) + + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 15.6' in body + ) + + @pytest.fixture(name="mock_client") def mock_client_fixture(): """Mock the prometheus client.""" diff --git a/tests/components/prosegur/__init__.py b/tests/components/prosegur/__init__.py new file mode 100644 index 00000000000..e0907bcaf9d --- /dev/null +++ b/tests/components/prosegur/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prosegur Alarm integration.""" diff --git a/tests/components/prosegur/common.py b/tests/components/prosegur/common.py new file mode 100644 index 00000000000..bed9d987ceb --- /dev/null +++ b/tests/components/prosegur/common.py @@ -0,0 +1,27 @@ +"""Common methods used across tests for Prosegur.""" +from homeassistant.components.prosegur import DOMAIN as PROSEGUR_DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CONTRACT = "1234abcd" + + +async def setup_platform(hass): + """Set up the Prosegur platform.""" + mock_entry = MockConfigEntry( + domain=PROSEGUR_DOMAIN, + data={ + "contract": "1234abcd", + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + "country": "PT", + }, + ) + mock_entry.add_to_hass(hass) + + assert await async_setup_component(hass, PROSEGUR_DOMAIN, {}) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/prosegur/test_alarm_control_panel.py b/tests/components/prosegur/test_alarm_control_panel.py new file mode 100644 index 00000000000..9ab0c0d37de --- /dev/null +++ b/tests/components/prosegur/test_alarm_control_panel.py @@ -0,0 +1,120 @@ +"""Tests for the Prosegur alarm control panel device.""" + +from unittest.mock import AsyncMock, patch + +from pyprosegur.installation import Status +from pytest import fixture, mark + +from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + SERVICE_ALARM_ARM_AWAY, + SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_DISARM, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_UNAVAILABLE, +) +from homeassistant.helpers import entity_component + +from .common import CONTRACT, setup_platform + +PROSEGUR_ALARM_ENTITY = f"alarm_control_panel.contract_{CONTRACT}" + + +@fixture +def mock_auth(): + """Setups authentication.""" + + with patch("pyprosegur.auth.Auth.login", return_value=True): + yield + + +@fixture(params=list(Status)) +def mock_status(request): + """Mock the status of the alarm.""" + + install = AsyncMock() + install.contract = "123" + install.installationId = "1234abcd" + install.status = request.param + + with patch("pyprosegur.installation.Installation.retrieve", return_value=install): + yield + + +async def test_entity_registry(hass, mock_auth, mock_status): + """Tests that the devices are registered in the entity registry.""" + await setup_platform(hass) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + 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 + + await hass.async_block_till_done() + + state = hass.states.get(PROSEGUR_ALARM_ENTITY) + + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "contract 1234abcd" + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 + + +async def test_connection_error(hass, mock_auth): + """Test the alarm control panel when connection can't be made to the cloud service.""" + + install = AsyncMock() + install.arm = AsyncMock(return_value=False) + install.arm_partially = AsyncMock(return_value=True) + install.disarm = AsyncMock(return_value=True) + install.status = Status.ARMED + + with patch("pyprosegur.installation.Installation.retrieve", return_value=install): + + await setup_platform(hass) + + await hass.async_block_till_done() + + with patch( + "pyprosegur.installation.Installation.retrieve", side_effect=ConnectionError + ): + + await entity_component.async_update_entity(hass, PROSEGUR_ALARM_ENTITY) + + state = hass.states.get(PROSEGUR_ALARM_ENTITY) + assert state.state == STATE_UNAVAILABLE + + +@mark.parametrize( + "code, alarm_service, alarm_state", + [ + (Status.ARMED, SERVICE_ALARM_ARM_AWAY, STATE_ALARM_ARMED_AWAY), + (Status.PARTIALLY, SERVICE_ALARM_ARM_HOME, STATE_ALARM_ARMED_HOME), + (Status.DISARMED, SERVICE_ALARM_DISARM, STATE_ALARM_DISARMED), + ], +) +async def test_arm(hass, mock_auth, code, alarm_service, alarm_state): + """Test the alarm control panel can be set to away.""" + + install = AsyncMock() + install.arm = AsyncMock(return_value=False) + install.arm_partially = AsyncMock(return_value=True) + install.disarm = AsyncMock(return_value=True) + install.status = code + + with patch("pyprosegur.installation.Installation.retrieve", return_value=install): + await setup_platform(hass) + + await hass.services.async_call( + ALARM_DOMAIN, + alarm_service, + {ATTR_ENTITY_ID: PROSEGUR_ALARM_ENTITY}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(PROSEGUR_ALARM_ENTITY) + assert state.state == alarm_state diff --git a/tests/components/prosegur/test_config_flow.py b/tests/components/prosegur/test_config_flow.py new file mode 100644 index 00000000000..bece0bae621 --- /dev/null +++ b/tests/components/prosegur/test_config_flow.py @@ -0,0 +1,227 @@ +"""Test the Prosegur Alarm config flow.""" +from unittest.mock import MagicMock, patch + +from pytest import mark + +from homeassistant import config_entries, setup +from homeassistant.components.prosegur.config_flow import CannotConnect, InvalidAuth +from homeassistant.components.prosegur.const import DOMAIN +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + 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"] == "form" + assert result["errors"] == {} + + install = MagicMock() + install.contract = "123" + + with patch( + "homeassistant.components.prosegur.config_flow.Installation.retrieve", + return_value=install, + ) as mock_retrieve, patch( + "homeassistant.components.prosegur.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Contract 123" + assert result2["data"] == { + "contract": "123", + "username": "test-username", + "password": "test-password", + "country": "PT", + } + assert len(mock_setup_entry.mock_calls) == 1 + + assert len(mock_retrieve.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyprosegur.auth.Auth", + side_effect=ConnectionRefusedError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyprosegur.installation.Installation", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_exception(hass): + """Test we handle unknown exceptions.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyprosegur.installation.Installation", + side_effect=ValueError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth_flow(hass): + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + install = MagicMock() + install.contract = "123" + + with patch( + "homeassistant.components.prosegur.config_flow.Installation.retrieve", + return_value=install, + ) as mock_installation, patch( + "homeassistant.components.prosegur.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "country": "PT", + "username": "test-username", + "password": "new_password", + } + + assert len(mock_installation.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +@mark.parametrize( + "exception, base_error", + [ + (CannotConnect, "cannot_connect"), + (InvalidAuth, "invalid_auth"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_error(hass, exception, base_error): + """Test a reauthentication flow with errors.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + "username": "test-username", + "password": "test-password", + "country": "PT", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.prosegur.config_flow.Installation.retrieve", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new_password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"]["base"] == base_error diff --git a/tests/components/prosegur/test_init.py b/tests/components/prosegur/test_init.py new file mode 100644 index 00000000000..2079d7a2b3c --- /dev/null +++ b/tests/components/prosegur/test_init.py @@ -0,0 +1,72 @@ +"""Tests prosegur setup.""" +from unittest.mock import MagicMock, patch + +from pytest import mark + +from homeassistant.components.prosegur import DOMAIN + +from tests.common import MockConfigEntry + + +@mark.parametrize( + "error", + [ + ConnectionRefusedError, + ConnectionError, + ], +) +async def test_setup_entry_fail_retrieve(hass, error): + """Test loading the Prosegur entry.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "country": "PT", + "contract": "xpto", + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "pyprosegur.auth.Auth.login", + side_effect=error, + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + +async def test_unload_entry(hass, aioclient_mock): + """Test unloading the Prosegur entry.""" + + aioclient_mock.post( + "https://smart.prosegur.com/smart-server/ws/access/login", + json={"data": {"token": "123456789"}}, + ) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "test-username", + "password": "test-password", + "country": "PT", + "contract": "xpto", + }, + ) + config_entry.add_to_hass(hass) + + install = MagicMock() + install.contract = "123" + + with patch( + "homeassistant.components.prosegur.config_flow.Installation.retrieve", + return_value=install, + ): + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(config_entry.entry_id) diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index 36c38a62b4c..57e65d15be4 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -64,6 +64,7 @@ MOCK_MANUAL = {"Config Mode": "Manual Entry", CONF_IP_ADDRESS: MOCK_HOST} MOCK_LOCATION = location.LocationInfo( "0.0.0.0", "US", + "USD", "CA", "California", "San Diego", diff --git a/tests/components/ps4/test_init.py b/tests/components/ps4/test_init.py index f57fada8c37..8c43bd5df90 100644 --- a/tests/components/ps4/test_init.py +++ b/tests/components/ps4/test_init.py @@ -56,6 +56,7 @@ MOCK_CONFIG = MockConfigEntry(domain=DOMAIN, data=MOCK_DATA, entry_id=MOCK_ENTRY MOCK_LOCATION = location.LocationInfo( "0.0.0.0", "US", + "USD", "CA", "California", "San Diego", diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 32eaaaab842..0468cc26a23 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -16,6 +16,7 @@ from homeassistant.const import TEMP_CELSIUS from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util +from tests.common import mock_registry from tests.components.recorder.common import wait_recording_done @@ -93,6 +94,63 @@ def test_compile_hourly_statistics(hass_recorder): assert stats == {} +def test_rename_entity(hass_recorder): + """Test statistics is migrated when entity_id is changed.""" + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + + entity_reg = mock_registry(hass) + reg_entry = entity_reg.async_get_or_create( + "sensor", + "test", + "unique_0000", + suggested_object_id="test1", + ) + assert reg_entry.entity_id == "sensor.test1" + + zero, four, states = record_states(hass) + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): + stats = statistics_during_period(hass, zero, **kwargs) + assert stats == {} + stats = get_last_statistics(hass, 0, "sensor.test1") + assert stats == {} + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + expected_1 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(14.915254237288135), + "min": approx(10.0), + "max": approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [ + {**expected_1, "statistic_id": "sensor.test1"}, + ] + expected_stats2 = [ + {**expected_1, "statistic_id": "sensor.test2"}, + ] + expected_stats99 = [ + {**expected_1, "statistic_id": "sensor.test99"}, + ] + + stats = statistics_during_period(hass, zero) + assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + + entity_reg.async_update_entity(reg_entry.entity_id, new_entity_id="sensor.test99") + hass.block_till_done() + + stats = statistics_during_period(hass, zero) + assert stats == {"sensor.test99": expected_stats99, "sensor.test2": expected_stats2} + + def record_states(hass): """Record some test states. diff --git a/tests/components/renault/__init__.py b/tests/components/renault/__init__.py new file mode 100644 index 00000000000..e4edc3b8539 --- /dev/null +++ b/tests/components/renault/__init__.py @@ -0,0 +1,159 @@ +"""Tests for the Renault integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any +from unittest.mock import patch + +from renault_api.kamereon import models, schemas +from renault_api.renault_vehicle import RenaultVehicle + +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import MOCK_VEHICLES + +from tests.common import MockConfigEntry, load_fixture + + +async def setup_renault_integration(hass: HomeAssistant): + """Create the Renault integration.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source="user", + data={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_2", + }, + unique_id="account_id_2", + options={}, + entry_id="1", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.renault.RenaultHub.attempt_login", return_value=True + ), patch("homeassistant.components.renault.RenaultHub.async_initialise"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +def get_fixtures(vehicle_type: str) -> dict[str, Any]: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + return { + "battery_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['battery_status']}") + if "battery_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleBatteryStatusDataSchema), + "charge_mode": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['charge_mode']}") + if "charge_mode" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleChargeModeDataSchema), + "cockpit": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['cockpit']}") + if "cockpit" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleCockpitDataSchema), + "hvac_status": schemas.KamereonVehicleDataResponseSchema.loads( + load_fixture(f"renault/{mock_vehicle['endpoints']['hvac_status']}") + if "hvac_status" in mock_vehicle["endpoints"] + else "{}" + ).get_attributes(schemas.KamereonVehicleHvacStatusDataSchema), + } + + +async def create_vehicle_proxy( + hass: HomeAssistant, vehicle_type: str +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + mock_fixtures = get_fixtures(vehicle_type) + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + return_value=mock_fixtures["battery_status"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + return_value=mock_fixtures["charge_mode"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + return_value=mock_fixtures["cockpit"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + return_value=mock_fixtures["hvac_status"], + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy + + +async def create_vehicle_proxy_with_side_effect( + hass: HomeAssistant, vehicle_type: str, side_effect: Any +) -> RenaultVehicleProxy: + """Create a vehicle proxy for testing unavailable entities.""" + mock_vehicle = MOCK_VEHICLES[vehicle_type] + + vehicles_response: models.KamereonVehiclesResponse = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture(f"renault/vehicle_{vehicle_type}.json") + ) + ) + vehicle_details = vehicles_response.vehicleLinks[0].vehicleDetails + vehicle = RenaultVehicle( + vehicles_response.accountId, + vehicle_details.vin, + websession=aiohttp_client.async_get_clientsession(hass), + ) + + vehicle_proxy = RenaultVehicleProxy( + hass, vehicle, vehicle_details, timedelta(seconds=300) + ) + with patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.endpoint_available", + side_effect=mock_vehicle["endpoints_available"], + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_battery_status", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_charge_mode", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_cockpit", + side_effect=side_effect, + ), patch( + "homeassistant.components.renault.renault_vehicle.RenaultVehicleProxy.get_hvac_status", + side_effect=side_effect, + ): + await vehicle_proxy.async_initialise() + return vehicle_proxy diff --git a/tests/components/renault/const.py b/tests/components/renault/const.py new file mode 100644 index 00000000000..be2adafd7be --- /dev/null +++ b/tests/components/renault/const.py @@ -0,0 +1,328 @@ +"""Constants for the Renault integration tests.""" +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DEVICE_CLASS_CHARGE_MODE, + DEVICE_CLASS_CHARGE_STATE, + DEVICE_CLASS_PLUG_STATE, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_TEMPERATURE, + LENGTH_KILOMETERS, + PERCENTAGE, + POWER_KILO_WATT, + STATE_UNKNOWN, + TEMP_CELSIUS, + TIME_MINUTES, + VOLUME_LITERS, +) + +# Mock config data to be used across multiple tests +MOCK_CONFIG = { + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + CONF_KAMEREON_ACCOUNT_ID: "account_id_1", + CONF_LOCALE: "fr_FR", +} + +MOCK_VEHICLES = { + "zoe_40": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X101VE", + }, + "endpoints_available": [ + True, # cockpit + True, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_ev.json", + "hvac_status": "hvac_status.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": "0.027", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.outside_temperature", + "unique_id": "vf1aaaaa555777999_outside_temperature", + "result": "8.0", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "zoe_50": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777999")}, + "manufacturer": "Renault", + "model": "Zoe", + "name": "REG-NUMBER", + "sw_version": "X102VE", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_not_charging.json", + "charge_mode": "charge_mode_schedule.json", + "cockpit": "cockpit_ev.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777999_battery_autonomy", + "result": "128", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777999_battery_level", + "result": "50", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777999_battery_temperature", + "result": STATE_UNKNOWN, + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777999_charge_mode", + "result": "schedule_mode", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777999_charge_state", + "result": "charge_error", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777999_charging_power", + "result": STATE_UNKNOWN, + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777999_charging_remaining_time", + "result": STATE_UNKNOWN, + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777999_mileage", + "result": "49114", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777999_plug_state", + "result": "unplugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_phev": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + True, # battery-status + True, # charge-mode + ], + "endpoints": { + "battery_status": "battery_status_charging.json", + "charge_mode": "charge_mode_always.json", + "cockpit": "cockpit_fuel.json", + }, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.battery_autonomy", + "unique_id": "vf1aaaaa555777123_battery_autonomy", + "result": "141", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.battery_level", + "unique_id": "vf1aaaaa555777123_battery_level", + "result": "60", + "unit": PERCENTAGE, + "class": DEVICE_CLASS_BATTERY, + }, + { + "entity_id": "sensor.battery_temperature", + "unique_id": "vf1aaaaa555777123_battery_temperature", + "result": "20", + "unit": TEMP_CELSIUS, + "class": DEVICE_CLASS_TEMPERATURE, + }, + { + "entity_id": "sensor.charge_mode", + "unique_id": "vf1aaaaa555777123_charge_mode", + "result": "always", + "class": DEVICE_CLASS_CHARGE_MODE, + }, + { + "entity_id": "sensor.charge_state", + "unique_id": "vf1aaaaa555777123_charge_state", + "result": "charge_in_progress", + "class": DEVICE_CLASS_CHARGE_STATE, + }, + { + "entity_id": "sensor.charging_power", + "unique_id": "vf1aaaaa555777123_charging_power", + "result": "27.0", + "unit": POWER_KILO_WATT, + "class": DEVICE_CLASS_ENERGY, + }, + { + "entity_id": "sensor.charging_remaining_time", + "unique_id": "vf1aaaaa555777123_charging_remaining_time", + "result": "145", + "unit": TIME_MINUTES, + }, + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.plug_state", + "unique_id": "vf1aaaaa555777123_plug_state", + "result": "plugged", + "class": DEVICE_CLASS_PLUG_STATE, + }, + ], + }, + "captur_fuel": { + "expected_device": { + "identifiers": {(DOMAIN, "VF1AAAAA555777123")}, + "manufacturer": "Renault", + "model": "Captur ii", + "name": "REG-NUMBER", + "sw_version": "XJB1SU", + }, + "endpoints_available": [ + True, # cockpit + False, # hvac-status + # Ignore, # battery-status + # Ignore, # charge-mode + ], + "endpoints": {"cockpit": "cockpit_fuel.json"}, + SENSOR_DOMAIN: [ + { + "entity_id": "sensor.fuel_autonomy", + "unique_id": "vf1aaaaa555777123_fuel_autonomy", + "result": "35", + "unit": LENGTH_KILOMETERS, + }, + { + "entity_id": "sensor.fuel_quantity", + "unique_id": "vf1aaaaa555777123_fuel_quantity", + "result": "3", + "unit": VOLUME_LITERS, + }, + { + "entity_id": "sensor.mileage", + "unique_id": "vf1aaaaa555777123_mileage", + "result": "5567", + "unit": LENGTH_KILOMETERS, + }, + ], + }, +} diff --git a/tests/components/renault/test_config_flow.py b/tests/components/renault/test_config_flow.py new file mode 100644 index 00000000000..c8b9c8c3e12 --- /dev/null +++ b/tests/components/renault/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test the Renault config flow.""" +from unittest.mock import AsyncMock, PropertyMock, patch + +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.renault.const import ( + CONF_KAMEREON_ACCOUNT_ID, + CONF_LOCALE, + DOMAIN, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import load_fixture + + +async def test_config_flow_single_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Failed credentials + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_credentials"} + + renault_account = AsyncMock() + type(renault_account).account_id = PropertyMock(return_value="account_id_1") + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + # Account list single + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_account.RenaultAccount.account_id", return_value="123" + ), patch( + "renault_api.renault_client.RenaultClient.get_api_accounts", + return_value=[renault_account], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_1" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1" + assert result["data"][CONF_LOCALE] == "fr_FR" + + +async def test_config_flow_no_account(hass: HomeAssistant): + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Account list empty + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "kamereon_no_account" + + +async def test_config_flow_multiple_accounts(hass: HomeAssistant): + """Test what happens if multiple Kamereon accounts are available.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + # Multiple accounts + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "homeassistant.components.renault.config_flow.RenaultHub.get_account_ids", + return_value=["account_id_1", "account_id_2"], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_LOCALE: "fr_FR", + CONF_USERNAME: "email@test.com", + CONF_PASSWORD: "test", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "kamereon" + + # Account selected + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "account_id_2" + assert result["data"][CONF_USERNAME] == "email@test.com" + assert result["data"][CONF_PASSWORD] == "test" + assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2" + assert result["data"][CONF_LOCALE] == "fr_FR" diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py new file mode 100644 index 00000000000..974155c3df9 --- /dev/null +++ b/tests/components/renault/test_init.py @@ -0,0 +1,85 @@ +"""Tests for Renault setup process.""" +from unittest.mock import AsyncMock, patch + +import aiohttp +import pytest +from renault_api.gigya.exceptions import InvalidCredentialsException +from renault_api.kamereon import schemas + +from homeassistant.components.renault import ( + RenaultHub, + async_setup_entry, + async_unload_entry, +) +from homeassistant.components.renault.const import DOMAIN +from homeassistant.components.renault.renault_vehicle import RenaultVehicleProxy +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import MOCK_CONFIG + +from tests.common import MockConfigEntry, load_fixture + + +async def test_setup_unload_and_reload_entry(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + renault_account = AsyncMock() + renault_account.get_vehicles.return_value = ( + schemas.KamereonVehiclesResponseSchema.loads( + load_fixture("renault/vehicle_zoe_40.json") + ) + ) + + with patch("renault_api.renault_session.RenaultSession.login"), patch( + "renault_api.renault_client.RenaultClient.get_api_account", + return_value=renault_account, + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert await async_setup_entry(hass, config_entry) + assert DOMAIN in hass.data and config_entry.unique_id in hass.data[DOMAIN] + assert isinstance(hass.data[DOMAIN][config_entry.unique_id], RenaultHub) + + renault_hub: RenaultHub = hass.data[DOMAIN][config_entry.unique_id] + assert len(renault_hub.vehicles) == 1 + assert isinstance( + renault_hub.vehicles["VF1AAAAA555777999"], RenaultVehicleProxy + ) + + # Unload the entry and verify that the data has been removed + assert await async_unload_entry(hass, config_entry) + assert config_entry.unique_id not in hass.data[DOMAIN] + + +async def test_setup_entry_bad_password(hass): + """Test entry setup and unload.""" + # Create a mock entry so we don't have to go through config flow + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=InvalidCredentialsException(403042, "invalid loginID or password"), + ): + # Set up the entry and assert that the values set during setup are where we expect + # them to be. + assert not await async_setup_entry(hass, config_entry) + + +async def test_setup_entry_exception(hass): + """Test ConfigEntryNotReady when API raises an exception during entry setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, entry_id="test", unique_id=123456 + ) + + # In this case we are testing the condition where async_setup_entry raises + # ConfigEntryNotReady. + with patch( + "renault_api.renault_session.RenaultSession.login", + side_effect=aiohttp.ClientConnectionError, + ), pytest.raises(ConfigEntryNotReady): + assert await async_setup_entry(hass, config_entry) diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py new file mode 100644 index 00000000000..8956fa7e7e6 --- /dev/null +++ b/tests/components/renault/test_sensor.py @@ -0,0 +1,212 @@ +"""Tests for Renault sensors.""" +from unittest.mock import PropertyMock, patch + +import pytest +from renault_api.kamereon import exceptions + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.setup import async_setup_component + +from . import ( + create_vehicle_proxy, + create_vehicle_proxy_with_side_effect, + setup_renault_integration, +) +from .const import MOCK_VEHICLES + +from tests.common import mock_device_registry, mock_registry + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensors(hass, vehicle_type): + """Test for Renault sensors.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy(hass, vehicle_type) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == expected_entity["result"] + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_empty(hass, vehicle_type): + """Test for Renault sensors with empty data from Renault.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect(hass, vehicle_type, {}) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("vehicle_type", MOCK_VEHICLES.keys()) +async def test_sensor_errors(hass, vehicle_type): + """Test for Renault sensors with temporary failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + invalid_upstream_exception = exceptions.InvalidUpstreamException( + "err.tech.500", + "Invalid response from the upstream server (The request sent to the GDC is erroneous) ; 502 Bad Gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, vehicle_type, invalid_upstream_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + mock_vehicle = MOCK_VEHICLES[vehicle_type] + assert len(device_registry.devices) == 1 + expected_device = mock_vehicle["expected_device"] + registry_entry = device_registry.async_get_device(expected_device["identifiers"]) + assert registry_entry is not None + assert registry_entry.identifiers == expected_device["identifiers"] + assert registry_entry.manufacturer == expected_device["manufacturer"] + assert registry_entry.name == expected_device["name"] + assert registry_entry.model == expected_device["model"] + assert registry_entry.sw_version == expected_device["sw_version"] + + expected_entities = mock_vehicle[SENSOR_DOMAIN] + assert len(entity_registry.entities) == len(expected_entities) + for expected_entity in expected_entities: + entity_id = expected_entity["entity_id"] + registry_entry = entity_registry.entities.get(entity_id) + assert registry_entry is not None + assert registry_entry.unique_id == expected_entity["unique_id"] + assert registry_entry.unit_of_measurement == expected_entity.get("unit") + assert registry_entry.device_class == expected_entity.get("class") + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +async def test_sensor_access_denied(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + access_denied_exception = exceptions.AccessDeniedException( + "err.func.403", + "Access is denied for this resource", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", access_denied_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 + + +async def test_sensor_not_supported(hass): + """Test for Renault sensors with access denied failure.""" + await async_setup_component(hass, "persistent_notification", {}) + entity_registry = mock_registry(hass) + device_registry = mock_device_registry(hass) + + not_supported_exception = exceptions.NotSupportedException( + "err.tech.501", + "This feature is not technically supported by this gateway", + ) + + vehicle_proxy = await create_vehicle_proxy_with_side_effect( + hass, "zoe_40", not_supported_exception + ) + + with patch( + "homeassistant.components.renault.RenaultHub.vehicles", + new_callable=PropertyMock, + return_value={ + vehicle_proxy.details.vin: vehicle_proxy, + }, + ), patch("homeassistant.components.renault.PLATFORMS", [SENSOR_DOMAIN]): + await setup_renault_integration(hass) + await hass.async_block_till_done() + + assert len(device_registry.devices) == 0 + assert len(entity_registry.entities) == 0 diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py index a52b390395a..5b76c6287a3 100644 --- a/tests/components/rfxtrx/test_binary_sensor.py +++ b/tests/components/rfxtrx/test_binary_sensor.py @@ -121,7 +121,7 @@ async def test_several(hass, rfxtrx): devices={ "0b1100cd0213c7f230010f71": {}, "0b1100100118cdea02010f70": {}, - "0b1100101118cdea02010f70": {}, + "0b1100100118cdea03010f70": {}, } ) mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) @@ -141,10 +141,20 @@ async def test_several(hass, rfxtrx): assert state.state == "off" assert state.attributes.get("friendly_name") == "AC 118cdea:2" - state = hass.states.get("binary_sensor.ac_1118cdea_2") + state = hass.states.get("binary_sensor.ac_118cdea_3") assert state assert state.state == "off" - assert state.attributes.get("friendly_name") == "AC 1118cdea:2" + assert state.attributes.get("friendly_name") == "AC 118cdea:3" + + # "2: Group on" + await rfxtrx.signal("0b1100100118cdea03040f70") + assert hass.states.get("binary_sensor.ac_118cdea_2").state == "on" + assert hass.states.get("binary_sensor.ac_118cdea_3").state == "on" + + # "2: Group off" + await rfxtrx.signal("0b1100100118cdea03030f70") + assert hass.states.get("binary_sensor.ac_118cdea_2").state == "off" + assert hass.states.get("binary_sensor.ac_118cdea_3").state == "off" async def test_discover(hass, rfxtrx_automatic): diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 12064911bb6..94adf4a980e 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -125,6 +125,62 @@ async def test_repetitions(hass, rfxtrx, repetitions): assert rfxtrx.transport.send.call_count == repetitions +async def test_switch_events(hass, rfxtrx): + """Event test with 2 switches.""" + entry_data = create_rfx_test_cfg( + devices={ + "0b1100cd0213c7f205010f51": {"signal_repetitions": 1}, + "0b1100cd0213c7f210010f51": {"signal_repetitions": 1}, + } + ) + mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("switch.ac_213c7f2_16") + assert state + assert state.state == "off" + assert state.attributes.get("friendly_name") == "AC 213c7f2:16" + + state = hass.states.get("switch.ac_213c7f2_5") + assert state + assert state.state == "off" + assert state.attributes.get("friendly_name") == "AC 213c7f2:5" + + # "16: On" + await rfxtrx.signal("0b1100100213c7f210010f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_16").state == "on" + + # "16: Off" + await rfxtrx.signal("0b1100100213c7f210000f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_16").state == "off" + + # "5: On" + await rfxtrx.signal("0b1100100213c7f205010f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "on" + assert hass.states.get("switch.ac_213c7f2_16").state == "off" + + # "5: Off" + await rfxtrx.signal("0b1100100213c7f205000f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_16").state == "off" + + # "16: Group on" + await rfxtrx.signal("0b1100100213c7f210040f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "on" + assert hass.states.get("switch.ac_213c7f2_16").state == "on" + + # "16: Group off" + await rfxtrx.signal("0b1100100213c7f210030f70") + assert hass.states.get("switch.ac_213c7f2_5").state == "off" + assert hass.states.get("switch.ac_213c7f2_16").state == "off" + + async def test_discover_switch(hass, rfxtrx_automatic): """Test with discovery of switches.""" rfxtrx = rfxtrx_automatic diff --git a/tests/components/rituals_perfume_genie/test_config_flow.py b/tests/components/rituals_perfume_genie/test_config_flow.py index e5c64dd54c9..df40405a56b 100644 --- a/tests/components/rituals_perfume_genie/test_config_flow.py +++ b/tests/components/rituals_perfume_genie/test_config_flow.py @@ -16,7 +16,8 @@ WRONG_PASSWORD = "wrong-passw0rd" def _mock_account(*_): account = MagicMock() account.authenticate = AsyncMock() - account.data = {CONF_EMAIL: TEST_EMAIL, ACCOUNT_HASH: "any"} + account.account_hash = "any" + account.email = TEST_EMAIL return account diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 0964343e453..eb0d0028417 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -136,7 +136,7 @@ async def test_availability( await setup_integration(hass, aioclient_mock) with patch( - "homeassistant.components.roku.Roku.update", side_effect=RokuError + "homeassistant.components.roku.coordinator.Roku.update", side_effect=RokuError ), patch("homeassistant.util.dt.utcnow", return_value=future): async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -336,21 +336,21 @@ async def test_services( """Test the different media player services.""" await setup_integration(hass, aioclient_mock) - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True ) remote_mock.assert_called_once_with("poweroff") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, blocking=True ) remote_mock.assert_called_once_with("poweron") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PAUSE, @@ -360,7 +360,7 @@ async def test_services( remote_mock.assert_called_once_with("play") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY, @@ -370,7 +370,7 @@ async def test_services( remote_mock.assert_called_once_with("play") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, @@ -380,7 +380,7 @@ async def test_services( remote_mock.assert_called_once_with("play") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, @@ -390,7 +390,7 @@ async def test_services( remote_mock.assert_called_once_with("forward") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, @@ -400,7 +400,7 @@ async def test_services( remote_mock.assert_called_once_with("reverse") - with patch("homeassistant.components.roku.Roku.launch") as launch_mock: + with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -414,7 +414,7 @@ async def test_services( launch_mock.assert_called_once_with("11") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -424,7 +424,7 @@ async def test_services( remote_mock.assert_called_once_with("home") - with patch("homeassistant.components.roku.Roku.launch") as launch_mock: + with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -434,7 +434,7 @@ async def test_services( launch_mock.assert_called_once_with("12") - with patch("homeassistant.components.roku.Roku.launch") as launch_mock: + with patch("homeassistant.components.roku.coordinator.Roku.launch") as launch_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_SELECT_SOURCE, @@ -458,14 +458,14 @@ async def test_tv_services( unique_id=TV_SERIAL, ) - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: TV_ENTITY_ID}, blocking=True ) remote_mock.assert_called_once_with("volume_up") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_DOWN, @@ -475,7 +475,7 @@ async def test_tv_services( remote_mock.assert_called_once_with("volume_down") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_VOLUME_MUTE, @@ -485,7 +485,7 @@ async def test_tv_services( remote_mock.assert_called_once_with("volume_mute") - with patch("homeassistant.components.roku.Roku.tune") as tune_mock: + with patch("homeassistant.components.roku.coordinator.Roku.tune") as tune_mock: await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -694,7 +694,7 @@ async def test_integration_services( """Test integration services.""" await setup_integration(hass, aioclient_mock) - with patch("homeassistant.components.roku.Roku.search") as search_mock: + with patch("homeassistant.components.roku.coordinator.Roku.search") as search_mock: await hass.services.async_call( DOMAIN, SERVICE_SEARCH, diff --git a/tests/components/roku/test_remote.py b/tests/components/roku/test_remote.py index 5b1c0509e1f..c0df380c1e8 100644 --- a/tests/components/roku/test_remote.py +++ b/tests/components/roku/test_remote.py @@ -42,7 +42,7 @@ async def test_main_services( """Test platform services.""" await setup_integration(hass, aioclient_mock) - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_OFF, @@ -51,7 +51,7 @@ async def test_main_services( ) remote_mock.assert_called_once_with("poweroff") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_TURN_ON, @@ -60,7 +60,7 @@ async def test_main_services( ) remote_mock.assert_called_once_with("poweron") - with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + with patch("homeassistant.components.roku.coordinator.Roku.remote") as remote_mock: await hass.services.async_call( REMOTE_DOMAIN, SERVICE_SEND_COMMAND, diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 1c9fdbcd0c5..64d0c95c084 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -117,6 +117,14 @@ MOCK_WS_ENTRY = { CONF_MODEL: "any", CONF_NAME: "any", } +MOCK_DEVICE_INFO = { + "device": { + "type": "Samsung SmartTV", + "name": "fake_name", + "modelName": "fake_model", + }, + "id": "123", +} AUTODETECT_LEGACY = { "name": "HomeAssistant", @@ -346,27 +354,32 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant, remote: Mock): assert result["step_id"] == "confirm" # missing authentication - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input="whatever" - ) - assert result["type"] == "abort" - assert result["reason"] == RESULT_AUTH_MISSING + + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVLegacyBridge.try_connect", + return_value=RESULT_AUTH_MISSING, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input="whatever" + ) + assert result["type"] == "abort" + assert result["reason"] == RESULT_AUTH_MISSING async def test_ssdp_legacy_not_supported(hass: HomeAssistant, remote: Mock): """Test starting a flow from discovery for not supported device.""" + + # confirm to add the entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + with patch( - "homeassistant.components.samsungtv.bridge.Remote", - side_effect=UnhandledResponse("Boom"), + "homeassistant.components.samsungtv.bridge.SamsungTVLegacyBridge.try_connect", + return_value=RESULT_NOT_SUPPORTED, ): - - # confirm to add the entry - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA - ) - assert result["type"] == "form" - assert result["step_id"] == "confirm" - # device not supported result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input="whatever" @@ -407,17 +420,10 @@ async def test_ssdp_websocket_not_supported(hass: HomeAssistant, remote: Mock): "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=WebSocketProtocolException("Boom"), ): - # confirm to add the entry + # device not supported result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, data=MOCK_SSDP_DATA ) - assert result["type"] == "form" - assert result["step_id"] == "confirm" - - # device not supported - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input="whatever" - ) assert result["type"] == "abort" assert result["reason"] == RESULT_NOT_SUPPORTED @@ -443,6 +449,9 @@ async def test_ssdp_not_successful(hass: HomeAssistant, remote: Mock): ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=OSError("Boom"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO, ): # confirm to add the entry @@ -468,6 +477,9 @@ async def test_ssdp_not_successful_2(hass: HomeAssistant, remote: Mock): ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=ConnectionFailure("Boom"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.device_info", + return_value=MOCK_DEVICE_INFO, ): # confirm to add the entry @@ -785,6 +797,55 @@ async def test_autodetect_websocket(hass: HomeAssistant, remote: Mock, remotews: assert entries[0].data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" +async def test_websocket_no_mac(hass: HomeAssistant, remote: Mock, remotews: Mock): + """Test for send key with autodetection of protocol.""" + with patch( + "homeassistant.components.samsungtv.bridge.Remote", + side_effect=OSError("Boom"), + ), patch( + "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", + return_value="fake_host", + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS" + ) as remotews, patch( + "getmac.get_mac_address", return_value="gg:hh:ii:ll:mm:nn" + ): + enter = Mock() + type(enter).token = PropertyMock(return_value="123456789") + remote = Mock() + remote.__enter__ = Mock(return_value=enter) + remote.__exit__ = Mock(return_value=False) + remote.rest_device_info.return_value = { + "id": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "device": { + "modelName": "82GXARRS", + "networkType": "lan", + "udn": "uuid:be9554b9-c9fb-41f4-8920-22da015376a4", + "name": "[TV] Living Room", + "type": "Samsung SmartTV", + }, + } + remotews.return_value = remote + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=MOCK_USER_DATA + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_METHOD] == "websocket" + assert result["data"][CONF_TOKEN] == "123456789" + assert result["data"][CONF_MAC] == "gg:hh:ii:ll:mm:nn" + assert remotews.call_count == 2 + assert remotews.call_args_list == [ + call(**AUTODETECT_WEBSOCKET_SSL), + call(**DEVICEINFO_WEBSOCKET_SSL), + ] + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].data[CONF_MAC] == "gg:hh:ii:ll:mm:nn" + + async def test_autodetect_auth_missing(hass: HomeAssistant, remote: Mock): """Test for send key with autodetection of protocol.""" with patch( diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 8967a20a01b..6070daeb8af 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -679,6 +679,7 @@ async def test_script_variables(hass, caplog): "script": { "script1": { "variables": { + "this_variable": "{{this.entity_id}}", "test_var": "from_config", "templated_config_var": "{{ var_from_service | default('config-default') }}", }, @@ -688,6 +689,8 @@ async def test_script_variables(hass, caplog): "data": { "value": "{{ test_var }}", "templated_config_var": "{{ templated_config_var }}", + "this_template": "{{this.entity_id}}", + "this_variable": "{{this_variable}}", }, }, ], @@ -731,6 +734,10 @@ async def test_script_variables(hass, caplog): assert len(mock_calls) == 1 assert mock_calls[0].data["value"] == "from_config" assert mock_calls[0].data["templated_config_var"] == "hello" + # Verify this available to all templates + assert mock_calls[0].data.get("this_template") == "script.script1" + # Verify this available during trigger variables rendering + assert mock_calls[0].data.get("this_variable") == "script.script1" await hass.services.async_call( "script", "script1", {"test_var": "from_service"}, blocking=True @@ -758,3 +765,34 @@ async def test_script_variables(hass, caplog): assert len(mock_calls) == 4 assert mock_calls[3].data["value"] == 1 + + +async def test_script_this_var_always(hass, caplog): + """Test script always has reference to this, even with no variabls are configured.""" + + assert await async_setup_component( + hass, + "script", + { + "script": { + "script1": { + "sequence": [ + { + "service": "test.script", + "data": { + "this_template": "{{this.entity_id}}", + }, + }, + ], + }, + }, + }, + ) + mock_calls = async_mock_service(hass, "test", "script") + + await hass.services.async_call("script", "script1", blocking=True) + + assert len(mock_calls) == 1 + # Verify this available to all templates + assert mock_calls[0].data.get("this_template") == "script.script1" + assert "Error rendering variables" not in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 99ede396381..58614e86a0e 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -44,6 +44,7 @@ TEMPERATURE_SENSOR_ATTRIBUTES = { @pytest.mark.parametrize( "device_class,unit,native_unit,mean,min,max", [ + (None, "%", "%", 16.440677, 10, 30), ("battery", "%", "%", 16.440677, 10, 30), ("battery", None, None, 16.440677, 10, 30), ("humidity", "%", "%", 16.440677, 10, 30), @@ -120,12 +121,6 @@ def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes attributes.pop("state_class") _, _states = record_states(hass, zero, "sensor.test5", attributes) states = {**states, **_states} - attributes["state_class"] = "measurement" - _, _states = record_states(hass, zero, "sensor.test6", attributes) - states = {**states, **_states} - attributes["state_class"] = "unsupported" - _, _states = record_states(hass, zero, "sensor.test7", attributes) - states = {**states, **_states} hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) @@ -626,6 +621,79 @@ def test_compile_hourly_statistics_fails(hass_recorder, caplog): assert "Error while processing event StatisticsTask" in caplog.text +@pytest.mark.parametrize( + "device_class,unit,native_unit,statistic_type", + [ + ("battery", "%", "%", "mean"), + ("battery", None, None, "mean"), + ("energy", "Wh", "kWh", "sum"), + ("energy", "kWh", "kWh", "sum"), + ("humidity", "%", "%", "mean"), + ("humidity", None, None, "mean"), + ("monetary", "USD", "USD", "sum"), + ("monetary", "None", "None", "sum"), + ("pressure", "Pa", "Pa", "mean"), + ("pressure", "hPa", "Pa", "mean"), + ("pressure", "mbar", "Pa", "mean"), + ("pressure", "inHg", "Pa", "mean"), + ("pressure", "psi", "Pa", "mean"), + ("temperature", "°C", "°C", "mean"), + ("temperature", "°F", "°C", "mean"), + ], +) +def test_list_statistic_ids( + hass_recorder, caplog, device_class, unit, native_unit, statistic_type +): + """Test listing future statistic ids.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + attributes = { + "device_class": device_class, + "last_reset": 0, + "state_class": "measurement", + "unit_of_measurement": unit, + } + hass.states.set("sensor.test1", 0, attributes=attributes) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + for stat_type in ["mean", "sum", "dogs"]: + statistic_ids = list_statistic_ids(hass, statistic_type=stat_type) + if statistic_type == stat_type: + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + else: + assert statistic_ids == [] + + +@pytest.mark.parametrize( + "_attributes", + [{**ENERGY_SENSOR_ATTRIBUTES, "last_reset": 0}, TEMPERATURE_SENSOR_ATTRIBUTES], +) +def test_list_statistic_ids_unsupported(hass_recorder, caplog, _attributes): + """Test listing future statistic ids for unsupported sensor.""" + hass = hass_recorder() + setup_component(hass, "sensor", {}) + attributes = dict(_attributes) + hass.states.set("sensor.test1", 0, attributes=attributes) + if "last_reset" in attributes: + attributes.pop("unit_of_measurement") + hass.states.set("last_reset.test2", 0, attributes=attributes) + attributes = dict(_attributes) + if "unit_of_measurement" in attributes: + attributes["unit_of_measurement"] = "invalid" + hass.states.set("sensor.test3", 0, attributes=attributes) + attributes.pop("unit_of_measurement") + hass.states.set("sensor.test4", 0, attributes=attributes) + attributes = dict(_attributes) + attributes["state_class"] = "invalid" + hass.states.set("sensor.test5", 0, attributes=attributes) + attributes.pop("state_class") + hass.states.set("sensor.test6", 0, attributes=attributes) + + def record_states(hass, zero, entity_id, attributes): """Record some test states. diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index f3a5d46f64b..196e47351bc 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -5,6 +5,8 @@ import os import tempfile from unittest.mock import MagicMock, patch +import pytest + from homeassistant.components import shell_command from homeassistant.setup import async_setup_component @@ -59,10 +61,7 @@ async def test_config_not_valid_service_names(hass): ) -@patch( - "homeassistant.components.shell_command.asyncio.subprocess" - ".create_subprocess_shell" -) +@patch("homeassistant.components.shell_command.asyncio.create_subprocess_shell") async def test_template_render_no_template(mock_call, hass): """Ensure shell_commands without templates get rendered properly.""" mock_call.return_value = mock_process_creator(error=False) @@ -82,10 +81,7 @@ async def test_template_render_no_template(mock_call, hass): assert cmd == "ls /bin" -@patch( - "homeassistant.components.shell_command.asyncio.subprocess" - ".create_subprocess_exec" -) +@patch("homeassistant.components.shell_command.asyncio.create_subprocess_exec") async def test_template_render(mock_call, hass): """Ensure shell_commands with templates get rendered properly.""" hass.states.async_set("sensor.test_state", "Works") @@ -109,10 +105,7 @@ async def test_template_render(mock_call, hass): assert ("ls", "/bin", "Works") == cmd -@patch( - "homeassistant.components.shell_command.asyncio.subprocess" - ".create_subprocess_shell" -) +@patch("homeassistant.components.shell_command.asyncio.create_subprocess_shell") @patch("homeassistant.components.shell_command._LOGGER.error") async def test_subprocess_error(mock_error, mock_call, hass): """Test subprocess that returns an error.""" @@ -166,6 +159,7 @@ async def test_stderr_captured(mock_output, hass): assert test_phrase.encode() + b"\n" == mock_output.call_args_list[0][0][-1] +@pytest.mark.skip(reason="disabled to check if it fixes flaky CI") async def test_do_no_run_forever(hass, caplog): """Test subprocesses terminate after the timeout.""" diff --git a/tests/components/siren/__init__.py b/tests/components/siren/__init__.py new file mode 100644 index 00000000000..a246822bdc5 --- /dev/null +++ b/tests/components/siren/__init__.py @@ -0,0 +1 @@ +"""Tests for the siren component.""" diff --git a/tests/components/siren/test_init.py b/tests/components/siren/test_init.py new file mode 100644 index 00000000000..729990ceaeb --- /dev/null +++ b/tests/components/siren/test_init.py @@ -0,0 +1,56 @@ +"""The tests for the siren component.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.siren import SirenEntity, process_turn_on_params +from homeassistant.components.siren.const import SUPPORT_TONES + + +class MockSirenEntity(SirenEntity): + """Mock siren device to use in tests.""" + + _attr_is_on = True + + def __init__(self, supported_features=0, available_tones=None): + """Initialize mock siren entity.""" + self._attr_supported_features = supported_features + self._attr_available_tones = available_tones + + +async def test_sync_turn_on(hass): + """Test if async turn_on calls sync turn_on.""" + siren = MockSirenEntity() + siren.hass = hass + + siren.turn_on = MagicMock() + await siren.async_turn_on() + + assert siren.turn_on.called + + +async def test_sync_turn_off(hass): + """Test if async turn_off calls sync turn_off.""" + siren = MockSirenEntity() + siren.hass = hass + + siren.turn_off = MagicMock() + await siren.async_turn_off() + + assert siren.turn_off.called + + +async def test_no_available_tones(hass): + """Test ValueError when siren advertises tones but has no available_tones.""" + siren = MockSirenEntity(SUPPORT_TONES) + siren.hass = hass + with pytest.raises(ValueError): + process_turn_on_params(siren, {"tone": "test"}) + + +async def test_missing_tones(hass): + """Test ValueError when setting a tone that is missing from available_tones.""" + siren = MockSirenEntity(SUPPORT_TONES, ["a", "b"]) + siren.hass = hass + with pytest.raises(ValueError): + process_turn_on_params(siren, {"tone": "test"}) diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 4af88e27fe4..70103c3a837 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -6,7 +6,11 @@ real HTTP calls are not initiated during testing. """ from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability -from homeassistant.components.sensor import DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + DEVICE_CLASSES, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASSES, +) from homeassistant.components.smartthings import sensor from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE from homeassistant.config_entries import ConfigEntryState @@ -33,6 +37,8 @@ async def test_mapping_integrity(): assert ( sensor_map.device_class in DEVICE_CLASSES ), sensor_map.device_class + if sensor_map.state_class: + assert sensor_map.state_class in STATE_CLASSES, sensor_map.state_class async def test_entity_state(hass, device_factory): @@ -95,6 +101,115 @@ async def test_entity_and_device_attributes(hass, device_factory): assert entry.manufacturer == "Unavailable" +async def test_energy_sensors_for_switch_device(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory( + "Switch_1", + [Capability.switch, Capability.power_meter, Capability.energy_meter], + {Attribute.switch: "off", Attribute.power: 355, Attribute.energy: 11.422}, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # Act + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + # Assert + state = hass.states.get("sensor.switch_1_energy_meter") + assert state + assert state.state == "11.422" + entry = entity_registry.async_get("sensor.switch_1_energy_meter") + assert entry + assert entry.unique_id == f"{device.device_id}.{Attribute.energy}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + state = hass.states.get("sensor.switch_1_power_meter") + assert state + assert state.state == "355" + entry = entity_registry.async_get("sensor.switch_1_power_meter") + assert entry + assert entry.unique_id == f"{device.device_id}.{Attribute.power}" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + +async def test_power_consumption_sensor(hass, device_factory): + """Test the attributes of the entity are correct.""" + # Arrange + device = device_factory( + "refrigerator", + [Capability.power_consumption_report], + { + Attribute.power_consumption: { + "energy": 1412002, + "deltaEnergy": 25, + "power": 109, + "powerEnergy": 24.304498331745464, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2021-07-30T16:45:25Z", + "end": "2021-07-30T16:58:33Z", + } + }, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # Act + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + # Assert + state = hass.states.get("sensor.refrigerator_energy") + assert state + assert state.state == "1412.002" + entry = entity_registry.async_get("sensor.refrigerator_energy") + assert entry + assert entry.unique_id == f"{device.device_id}.energy" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + state = hass.states.get("sensor.refrigerator_power") + assert state + assert state.state == "109" + entry = entity_registry.async_get("sensor.refrigerator_power") + assert entry + assert entry.unique_id == f"{device.device_id}.power" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + device = device_factory( + "vacuum", + [Capability.power_consumption_report], + {Attribute.power_consumption: {}}, + ) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + # Act + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + # Assert + state = hass.states.get("sensor.vacuum_energy") + assert state + assert state.state == "unknown" + entry = entity_registry.async_get("sensor.vacuum_energy") + assert entry + assert entry.unique_id == f"{device.device_id}.energy" + entry = device_registry.async_get_device({(DOMAIN, device.device_id)}) + assert entry + assert entry.name == device.label + assert entry.model == device.device_type_name + assert entry.manufacturer == "Unavailable" + + async def test_update_from_signal(hass, device_factory): """Test the binary_sensor updates when receiving a signal.""" # Arrange diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 7c202fad12e..c884d601baf 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -7,11 +7,7 @@ real HTTP calls are not initiated during testing. from pysmartthings import Attribute, Capability from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE -from homeassistant.components.switch import ( - ATTR_CURRENT_POWER_W, - ATTR_TODAY_ENERGY_KWH, - DOMAIN as SWITCH_DOMAIN, -) +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -72,8 +68,6 @@ async def test_turn_on(hass, device_factory): state = hass.states.get("switch.switch_1") assert state is not None assert state.state == "on" - assert state.attributes[ATTR_CURRENT_POWER_W] == 355 - assert state.attributes[ATTR_TODAY_ENERGY_KWH] == 11.422 async def test_update_from_signal(hass, device_factory): diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 7c5b4ac91ef..294e243901a 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -49,8 +49,8 @@ def config_entry_fixture(): @pytest.fixture(name="soco") def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): - """Create a mock pysonos SoCo fixture.""" - with patch("pysonos.SoCo", autospec=True) as mock, patch( + """Create a mock soco SoCo fixture.""" + with patch("homeassistant.components.sonos.SoCo", autospec=True) as mock, patch( "socket.gethostbyname", return_value="192.168.42.2" ): mock_soco = mock.return_value @@ -76,7 +76,7 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): @pytest.fixture(name="discover", autouse=True) def discover_fixture(soco): - """Create a mock pysonos discover fixture.""" + """Create a mock soco discover fixture.""" def do_callback(hass, callback, *args, **kwargs): callback( diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 9dd308ae28f..90ffdb155ea 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries, core, setup from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN -@patch("homeassistant.components.sonos.config_flow.pysonos.discover", return_value=True) +@patch("homeassistant.components.sonos.config_flow.soco.discover", return_value=True) async def test_user_form(discover_mock: MagicMock, hass: core.HomeAssistant): """Test we get the user initiated form.""" await setup.async_setup_component(hass, "persistent_notification", {}) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 86ec90f32b8..bf4b5d5e7cc 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -13,7 +13,7 @@ async def test_creating_entry_sets_up_media_player(hass): with patch( "homeassistant.components.sonos.media_player.async_setup_entry", return_value=mock_coro(True), - ) as mock_setup, patch("pysonos.discover", return_value=True): + ) as mock_setup, patch("soco.discover", return_value=True): result = await hass.config_entries.flow.async_init( sonos.DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -33,7 +33,7 @@ async def test_configuring_sonos_creates_entry(hass): """Test that specifying config will create an entry.""" with patch( "homeassistant.components.sonos.async_setup_entry", return_value=mock_coro(True) - ) as mock_setup, patch("pysonos.discover", return_value=True): + ) as mock_setup, patch("soco.discover", return_value=True): await async_setup_component( hass, sonos.DOMAIN, @@ -48,7 +48,7 @@ async def test_not_configuring_sonos_not_creates_entry(hass): """Test that no config will not create an entry.""" with patch( "homeassistant.components.sonos.async_setup_entry", return_value=mock_coro(True) - ) as mock_setup, patch("pysonos.discover", return_value=True): + ) as mock_setup, patch("soco.discover", return_value=True): await async_setup_component(hass, sonos.DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index cba6967463a..0e4af2071b2 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,5 +1,8 @@ """Tests for the Sonos Media Player platform.""" +from unittest.mock import PropertyMock + import pytest +from soco.exceptions import NotSupportedException from homeassistant.components.sonos import DATA_SONOS, DOMAIN, media_player from homeassistant.const import STATE_IDLE @@ -40,6 +43,16 @@ async def test_async_setup_entry_discover(hass, config_entry, discover): assert media_player.state == STATE_IDLE +async def test_discovery_ignore_unsupported_device(hass, config_entry, soco, caplog): + """Test discovery setup.""" + message = f"GetVolume not supported on {soco.ip_address}" + type(soco).volume = PropertyMock(side_effect=NotSupportedException(message)) + await setup_platform(hass, config_entry, {}) + + assert message in caplog.text + assert not hass.data[DATA_SONOS].discovered + + async def test_services(hass, config_entry, config, hass_read_only_user): """Test join/unjoin requires control access.""" await setup_platform(hass, config_entry, config) diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 80f050fe6fc..18cd87ca9be 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,5 +1,5 @@ """Tests for the Sonos battery sensor platform.""" -from pysonos.exceptions import NotSupportedException +from soco.exceptions import NotSupportedException from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE diff --git a/tests/components/speedtestdotnet/conftest.py b/tests/components/speedtestdotnet/conftest.py new file mode 100644 index 00000000000..78a864cb934 --- /dev/null +++ b/tests/components/speedtestdotnet/conftest.py @@ -0,0 +1,16 @@ +"""Conftest for speedtestdotnet.""" +from unittest.mock import patch + +import pytest + +from tests.components.speedtestdotnet import MOCK_RESULTS, MOCK_SERVERS + + +@pytest.fixture(autouse=True) +def mock_api(): + """Mock entry setup.""" + with patch("speedtest.Speedtest") as mock_api: + mock_api.return_value.get_servers.return_value = MOCK_SERVERS + mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] + mock_api.return_value.results.dict.return_value = MOCK_RESULTS + yield mock_api diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py index a7a65511ee5..727a5778603 100644 --- a/tests/components/speedtestdotnet/test_config_flow.py +++ b/tests/components/speedtestdotnet/test_config_flow.py @@ -1,8 +1,7 @@ """Tests for SpeedTest config flow.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import MagicMock -import pytest from speedtest import NoMatchedServers from homeassistant import config_entries, data_entry_flow @@ -15,23 +14,12 @@ from homeassistant.components.speedtestdotnet.const import ( SENSOR_TYPES, ) from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL - -from . import MOCK_SERVERS +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.fixture(name="mock_setup") -def mock_setup(): - """Mock entry setup.""" - with patch( - "homeassistant.components.speedtestdotnet.async_setup_entry", - return_value=True, - ): - yield - - -async def test_flow_works(hass, mock_setup): +async def test_flow_works(hass: HomeAssistant) -> None: """Test user config.""" result = await hass.config_entries.flow.async_init( speedtestdotnet.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,92 +31,104 @@ async def test_flow_works(hass, mock_setup): result["flow_id"], user_input={} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "SpeedTest" -async def test_import_fails(hass, mock_setup): +async def test_import_fails(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test import step fails if server_id is not valid.""" - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_servers.side_effect = NoMatchedServers - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "223", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "wrong_server_id" + mock_api.return_value.get_servers.side_effect = NoMatchedServers + result = await hass.config_entries.flow.async_init( + speedtestdotnet.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_SERVER_ID: "223", + CONF_MANUAL: True, + CONF_SCAN_INTERVAL: timedelta(minutes=1), + CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "wrong_server_id" -async def test_import_success(hass, mock_setup): +async def test_import_success(hass): """Test import step is successful if server_id is valid.""" - with patch("speedtest.Speedtest"): - result = await hass.config_entries.flow.async_init( - speedtestdotnet.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_SERVER_ID: "1", - CONF_MANUAL: True, - CONF_SCAN_INTERVAL: timedelta(minutes=1), - CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), - }, - ) + result = await hass.config_entries.flow.async_init( + speedtestdotnet.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_SERVER_ID: "1", + CONF_MANUAL: True, + CONF_SCAN_INTERVAL: timedelta(minutes=1), + CONF_MONITORED_CONDITIONS: list(SENSOR_TYPES), + }, + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "SpeedTest" - assert result["data"][CONF_SERVER_ID] == "1" - assert result["data"][CONF_MANUAL] is True - assert result["data"][CONF_SCAN_INTERVAL] == 1 + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "SpeedTest" + assert result["data"][CONF_SERVER_ID] == "1" + assert result["data"][CONF_MANUAL] is True + assert result["data"][CONF_SCAN_INTERVAL] == 1 -async def test_options(hass): +async def test_options(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test updating options.""" entry = MockConfigEntry( domain=DOMAIN, title="SpeedTest", - data={}, - options={}, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_servers.return_value = MOCK_SERVERS - await hass.config_entries.async_setup(entry.entry_id) + 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"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SCAN_INTERVAL: 30, - CONF_MANUAL: False, - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == { + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: True, + } + await hass.async_block_till_done() + + assert hass.data[DOMAIN].update_interval is None + + # test setting the option to update periodically + result2 = await hass.config_entries.options.async_init(entry.entry_id) + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SERVER_ID: "1", CONF_SCAN_INTERVAL: 30, CONF_MANUAL: False, - } + }, + ) + await hass.async_block_till_done() + + assert hass.data[DOMAIN].update_interval == timedelta(minutes=30) -async def test_integration_already_configured(hass): +async def test_integration_already_configured(hass: HomeAssistant) -> None: """Test integration is already configured.""" entry = MockConfigEntry( domain=DOMAIN, - data={}, - options={}, ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index 30d3d2a1d63..fcadb0e9931 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -1,79 +1,113 @@ """Tests for SpeedTest integration.""" -from unittest.mock import patch +from unittest.mock import MagicMock import speedtest -from homeassistant import config_entries -from homeassistant.components import speedtestdotnet -from homeassistant.setup import async_setup_component +from homeassistant.components.speedtestdotnet.const import ( + CONF_MANUAL, + CONF_SERVER_ID, + CONF_SERVER_NAME, + DOMAIN, + SPEED_TEST_SERVICE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_SCAN_INTERVAL, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_setup_with_config(hass): - """Test that we import the config and setup the integration.""" - config = { - speedtestdotnet.DOMAIN: { - speedtestdotnet.CONF_SERVER_ID: "1", - speedtestdotnet.CONF_MANUAL: True, - speedtestdotnet.CONF_SCAN_INTERVAL: "00:01:00", - } - } - with patch("speedtest.Speedtest"): - assert await async_setup_component(hass, speedtestdotnet.DOMAIN, config) - - -async def test_successful_config_entry(hass): +async def test_successful_config_entry(hass: HomeAssistant) -> None: """Test that SpeedTestDotNet is configured successfully.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, + domain=DOMAIN, data={}, + options={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + CONF_SCAN_INTERVAL: 30, + CONF_MANUAL: False, + }, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest"), patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ) as forward_entry_setup: - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is config_entries.ConfigEntryState.LOADED - assert forward_entry_setup.mock_calls[0][1] == ( - entry, - "sensor", - ) + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN] + assert hass.services.has_service(DOMAIN, SPEED_TEST_SERVICE) -async def test_setup_failed(hass): +async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test SpeedTestDotNet failed due to an error.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, - data={}, + domain=DOMAIN, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest", side_effect=speedtest.ConfigRetrievalError): - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + mock_api.side_effect = speedtest.ConfigRetrievalError + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass): +async def test_unload_entry(hass: HomeAssistant) -> None: """Test removing SpeedTestDotNet.""" entry = MockConfigEntry( - domain=speedtestdotnet.DOMAIN, - data={}, + domain=DOMAIN, ) entry.add_to_hass(hass) - with patch("speedtest.Speedtest"): - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert speedtestdotnet.DOMAIN not in hass.data + assert entry.state is ConfigEntryState.NOT_LOADED + assert DOMAIN not in hass.data + + +async def test_server_not_found(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test configured server id is not found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN] + + mock_api.return_value.get_servers.side_effect = speedtest.NoMatchedServers + await hass.data[DOMAIN].async_refresh() + await hass.async_block_till_done() + state = hass.states.get("sensor.speedtest_ping") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +async def test_get_best_server_error(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test configured server id is not found.""" + + entry = MockConfigEntry( + domain=DOMAIN, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN] + + mock_api.return_value.get_best_server.side_effect = ( + speedtest.SpeedtestBestServerFailure( + "Unable to connect to servers to test latency." + ) + ) + await hass.data[DOMAIN].async_refresh() + await hass.async_block_till_done() + state = hass.states.get("sensor.speedtest_ping") + assert state is not None + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index c08a9f3304f..11db05d2994 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -1,26 +1,28 @@ """Tests for SpeedTest sensors.""" -from unittest.mock import patch +from unittest.mock import MagicMock from homeassistant.components import speedtestdotnet from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet.const import DEFAULT_NAME, SENSOR_TYPES +from homeassistant.core import HomeAssistant from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES from tests.common import MockConfigEntry -async def test_speedtestdotnet_sensors(hass): +async def test_speedtestdotnet_sensors( + hass: HomeAssistant, mock_api: MagicMock +) -> None: """Test sensors created for speedtestdotnet integration.""" entry = MockConfigEntry(domain=speedtestdotnet.DOMAIN, data={}) entry.add_to_hass(hass) - with patch("speedtest.Speedtest") as mock_api: - mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] - mock_api.return_value.results.dict.return_value = MOCK_RESULTS + mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] + mock_api.return_value.results.dict.return_value = MOCK_RESULTS - 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() assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 @@ -28,4 +30,5 @@ async def test_speedtestdotnet_sensors(hass): sensor = hass.states.get( f"sensor.{DEFAULT_NAME}_{SENSOR_TYPES[sensor_type][0]}" ) + assert sensor assert sensor.state == MOCK_STATES[sensor_type] diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 568a2261fee..34ca1b7228e 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -790,3 +790,65 @@ async def test_async_detect_interfaces_setting_empty_route(hass): (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), (IPv4Address("192.168.1.5"), None), } + + +async def test_bind_failure_skips_adapter(hass, caplog): + """Test that an adapter with a bind failure is skipped.""" + mock_get_ssdp = { + "mock-domain": [ + { + ssdp.ATTR_UPNP_DEVICE_TYPE: "ABC", + } + ] + } + create_args = [] + did_search = 0 + + @callback + def _callback(*_): + nonlocal did_search + did_search += 1 + pass + + def _generate_failing_ssdp_listener(*args, **kwargs): + create_args.append([args, kwargs]) + listener = SSDPListener(*args, **kwargs) + + async def _async_callback(*_): + if kwargs["source_ip"] == IPv6Address("2001:db8::"): + raise OSError + pass + + listener.async_start = _async_callback + listener.async_search = _callback + return listener + + with patch( + "homeassistant.components.ssdp.async_get_ssdp", + return_value=mock_get_ssdp, + ), patch( + "homeassistant.components.ssdp.SSDPListener", + new=_generate_failing_ssdp_listener, + ), patch( + "homeassistant.components.ssdp.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + assert await async_setup_component(hass, ssdp.DOMAIN, {ssdp.DOMAIN: {}}) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + argset = set() + for argmap in create_args: + argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip"))) + + assert argset == { + (IPv6Address("2001:db8::"), None), + (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), + (IPv4Address("192.168.1.5"), None), + } + assert "Failed to setup listener for" in caplog.text + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + await hass.async_block_till_done() + assert did_search == 2 diff --git a/tests/components/statsd/test_init.py b/tests/components/statsd/test_init.py index a0e5fd51669..62808491c2d 100644 --- a/tests/components/statsd/test_init.py +++ b/tests/components/statsd/test_init.py @@ -110,10 +110,8 @@ async def test_event_listener_attr_details(hass, mock_client): handler_method(MagicMock(data={"new_state": state})) mock_client.gauge.assert_has_calls( [ - mock.call("%s.state" % state.entity_id, out, statsd.DEFAULT_RATE), - mock.call( - "%s.attribute_key" % state.entity_id, 3.2, statsd.DEFAULT_RATE - ), + mock.call(f"{state.entity_id}.state", out, statsd.DEFAULT_RATE), + mock.call(f"{state.entity_id}.attribute_key", 3.2, statsd.DEFAULT_RATE), ] ) diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 793038c6770..ffbeb44d79e 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -149,6 +149,7 @@ class FakePyAvBuffer: self.segments = [] self.audio_packets = [] self.video_packets = [] + self.memory_file: io.BytesIO | None = None def add_stream(self, template=None): """Create an output buffer that captures packets for test to examine.""" @@ -171,10 +172,13 @@ class FakePyAvBuffer: """Capture a packet for tests to examine.""" # Forward to appropriate FakeStream packet.stream.mux(packet) + # Make new init/part data available to the worker + self.memory_file.write(b"0") def close(self): """Close the buffer.""" - return + # Make the final segment data available to the worker + self.memory_file.write(b"0") def capture_output_segment(self, segment): """Capture the output segment for tests to inspect.""" @@ -201,23 +205,11 @@ class MockPyAv: def open(self, stream_source, *args, **kwargs): """Return a stream or buffer depending on args.""" if isinstance(stream_source, io.BytesIO): + self.capture_buffer.memory_file = stream_source return self.capture_buffer return self.container -class MockFlushPart: - """Class to hold a wrapper function for check_flush_part.""" - - # Wrap this method with a preceding write so the BytesIO pointer moves - check_flush_part = SegmentBuffer.check_flush_part - - @classmethod - def wrapped_check_flush_part(cls, segment_buffer, packet): - """Wrap check_flush_part to also advance the memory_file pointer.""" - segment_buffer._memory_file.write(b"0") - return cls.check_flush_part(segment_buffer, packet) - - async def async_decode_stream(hass, packets, py_av=None): """Start a stream worker that decodes incoming stream packets into output segments.""" stream = Stream(hass, STREAM_SOURCE, {}) @@ -230,10 +222,6 @@ async def async_decode_stream(hass, packets, py_av=None): with patch("av.open", new=py_av.open), patch( "homeassistant.components.stream.core.StreamOutput.put", side_effect=py_av.capture_buffer.capture_output_segment, - ), patch( - "homeassistant.components.stream.worker.SegmentBuffer.check_flush_part", - side_effect=MockFlushPart.wrapped_check_flush_part, - autospec=True, ): segment_buffer = SegmentBuffer(stream.outputs) stream_worker(STREAM_SOURCE, {}, segment_buffer, threading.Event()) @@ -606,17 +594,13 @@ async def test_update_stream_source(hass): nonlocal last_stream_source if not isinstance(stream_source, io.BytesIO): last_stream_source = stream_source - # Let test know the thread is running - worker_open.set() - # Block worker thread until test wakes up - worker_wake.wait() + # Let test know the thread is running + worker_open.set() + # Block worker thread until test wakes up + worker_wake.wait() return py_av.open(stream_source, args, kwargs) - with patch("av.open", new=blocking_open), patch( - "homeassistant.components.stream.worker.SegmentBuffer.check_flush_part", - side_effect=MockFlushPart.wrapped_check_flush_part, - autospec=True, - ): + with patch("av.open", new=blocking_open): stream.start() assert worker_open.wait(TIMEOUT) assert last_stream_source == STREAM_SOURCE diff --git a/tests/components/switcher_kis/__init__.py b/tests/components/switcher_kis/__init__.py index 46fbe073ab0..671af5e11b9 100644 --- a/tests/components/switcher_kis/__init__.py +++ b/tests/components/switcher_kis/__init__.py @@ -1 +1,16 @@ """Test cases and object for the Switcher integration tests.""" +from homeassistant.components.switcher_kis.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the Switcher integration in Home Assistant.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + 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/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index fda5f39922d..3578e3ac6c9 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -1,194 +1,61 @@ """Common fixtures and objects for the Switcher integration tests.""" -from __future__ import annotations +from unittest.mock import AsyncMock, Mock, patch -from asyncio import Queue -from datetime import datetime -from typing import Any, Generator -from unittest.mock import AsyncMock, patch - -from pytest import fixture - -from .consts import ( - DUMMY_AUTO_OFF_SET, - DUMMY_DEVICE_ID, - DUMMY_DEVICE_NAME, - DUMMY_DEVICE_PASSWORD, - DUMMY_DEVICE_STATE, - DUMMY_ELECTRIC_CURRENT, - DUMMY_IP_ADDRESS, - DUMMY_MAC_ADDRESS, - DUMMY_PHONE_ID, - DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, -) +import pytest -@patch("aioswitcher.devices.SwitcherV2Device") -class MockSwitcherV2Device: - """Class for mocking the aioswitcher.devices.SwitcherV2Device object.""" +@pytest.fixture +def mock_bridge(request): + """Return a mocked SwitcherBridge.""" + with patch( + "homeassistant.components.switcher_kis.utils.SwitcherBridge", autospec=True + ) as bridge_mock: + bridge = bridge_mock.return_value - def __init__(self) -> None: - """Initialize the object.""" - self._last_state_change = datetime.now() + bridge.devices = [] + if hasattr(request, "param") and request.param: + bridge.devices = request.param - @property - def device_id(self) -> str: - """Return the device id.""" - return DUMMY_DEVICE_ID + async def start(): + bridge.is_running = True - @property - def ip_addr(self) -> str: - """Return the ip address.""" - return DUMMY_IP_ADDRESS + for device in bridge.devices: + bridge_mock.call_args[0][0](device) - @property - def mac_addr(self) -> str: - """Return the mac address.""" - return DUMMY_MAC_ADDRESS + def mock_callbacks(devices): + for device in devices: + bridge_mock.call_args[0][0](device) - @property - def name(self) -> str: - """Return the device name.""" - return DUMMY_DEVICE_NAME + async def stop(): + bridge.is_running = False - @property - def state(self) -> str: - """Return the device state.""" - return DUMMY_DEVICE_STATE + bridge.start = AsyncMock(side_effect=start) + bridge.mock_callbacks = Mock(side_effect=mock_callbacks) + bridge.stop = AsyncMock(side_effect=stop) - @property - def remaining_time(self) -> str | None: - """Return the time left to auto-off.""" - return DUMMY_REMAINING_TIME - - @property - def auto_off_set(self) -> str: - """Return the auto-off configuration value.""" - return DUMMY_AUTO_OFF_SET - - @property - def power_consumption(self) -> int: - """Return the power consumption in watts.""" - return DUMMY_POWER_CONSUMPTION - - @property - def electric_current(self) -> float: - """Return the power consumption in amps.""" - return DUMMY_ELECTRIC_CURRENT - - @property - def phone_id(self) -> str: - """Return the phone id.""" - return DUMMY_PHONE_ID - - @property - def device_password(self) -> str: - """Return the device password.""" - return DUMMY_DEVICE_PASSWORD - - @property - def last_data_update(self) -> datetime: - """Return the timestamp of the last update.""" - return datetime.now() - - @property - def last_state_change(self) -> datetime: - """Return the timestamp of the state change.""" - return self._last_state_change + yield bridge -@fixture(name="mock_bridge") -def mock_bridge_fixture() -> Generator[None, Any, None]: - """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" - queue = Queue() - - async def mock_queue(): - """Mock asyncio's Queue.""" - await queue.put(MockSwitcherV2Device()) - return await queue.get() - - mock_bridge = AsyncMock() +@pytest.fixture +def mock_api(): + """Fixture for mocking aioswitcher.api.SwitcherApi.""" + api_mock = AsyncMock() patchers = [ patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.start", - new=mock_bridge, + "homeassistant.components.switcher_kis.switch.SwitcherApi.connect", + new=api_mock, ), patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.stop", - new=mock_bridge, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.queue", - get=mock_queue, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.running", - return_value=True, + "homeassistant.components.switcher_kis.switch.SwitcherApi.disconnect", + new=api_mock, ), ] for patcher in patchers: patcher.start() - yield - - for patcher in patchers: - patcher.stop() - - -@fixture(name="mock_failed_bridge") -def mock_failed_bridge_fixture() -> Generator[None, Any, None]: - """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" - - async def mock_queue(): - """Mock asyncio's Queue.""" - raise RuntimeError - - patchers = [ - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.start", - return_value=None, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.stop", - return_value=None, - ), - patch( - "homeassistant.components.switcher_kis.SwitcherV2Bridge.queue", - get=mock_queue, - ), - ] - - for patcher in patchers: - patcher.start() - - yield - - for patcher in patchers: - patcher.stop() - - -@fixture(name="mock_api") -def mock_api_fixture() -> Generator[AsyncMock, Any, None]: - """Fixture for mocking aioswitcher.api.SwitcherV2Api.""" - mock_api = AsyncMock() - - patchers = [ - patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.connect", - new=mock_api, - ), - patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.disconnect", - new=mock_api, - ), - ] - - for patcher in patchers: - patcher.start() - - yield + yield api_mock for patcher in patchers: patcher.stop() diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py index ab5951710f4..e200d92e026 100644 --- a/tests/components/switcher_kis/consts.py +++ b/tests/components/switcher_kis/consts.py @@ -1,5 +1,12 @@ """Constants for the Switcher integration tests.""" +from aioswitcher.device import ( + DeviceState, + DeviceType, + SwitcherPowerPlug, + SwitcherWaterHeater, +) + from homeassistant.components.switcher_kis import ( CONF_DEVICE_ID, CONF_DEVICE_PASSWORD, @@ -8,27 +15,54 @@ from homeassistant.components.switcher_kis import ( ) DUMMY_AUTO_OFF_SET = "01:30:00" -DUMMY_TIMER_MINUTES_SET = "90" -DUMMY_DEVICE_ID = "a123bc" -DUMMY_DEVICE_NAME = "Device Name" +DUMMY_AUTO_SHUT_DOWN = "02:00:00" +DUMMY_DEVICE_ID1 = "a123bc" +DUMMY_DEVICE_ID2 = "cafe12" +DUMMY_DEVICE_NAME1 = "Plug 23BC" +DUMMY_DEVICE_NAME2 = "Heater FE12" DUMMY_DEVICE_PASSWORD = "12345678" -DUMMY_DEVICE_STATE = "on" -DUMMY_ELECTRIC_CURRENT = 12.8 -DUMMY_ICON = "mdi:dummy-icon" -DUMMY_IP_ADDRESS = "192.168.100.157" -DUMMY_MAC_ADDRESS = "A1:B2:C3:45:67:D8" -DUMMY_NAME = "boiler" +DUMMY_ELECTRIC_CURRENT1 = 0.5 +DUMMY_ELECTRIC_CURRENT2 = 12.8 +DUMMY_IP_ADDRESS1 = "192.168.100.157" +DUMMY_IP_ADDRESS2 = "192.168.100.158" +DUMMY_MAC_ADDRESS1 = "A1:B2:C3:45:67:D8" +DUMMY_MAC_ADDRESS2 = "A1:B2:C3:45:67:D9" DUMMY_PHONE_ID = "1234" -DUMMY_POWER_CONSUMPTION = 2780 +DUMMY_POWER_CONSUMPTION1 = 100 +DUMMY_POWER_CONSUMPTION2 = 2780 DUMMY_REMAINING_TIME = "01:29:32" +DUMMY_TIMER_MINUTES_SET = "90" -# Adjust if any modification were made to DUMMY_DEVICE_NAME -SWITCH_ENTITY_ID = "switch.device_name" - -MANDATORY_CONFIGURATION = { +YAML_CONFIG = { DOMAIN: { CONF_PHONE_ID: DUMMY_PHONE_ID, - CONF_DEVICE_ID: DUMMY_DEVICE_ID, + CONF_DEVICE_ID: DUMMY_DEVICE_ID1, CONF_DEVICE_PASSWORD: DUMMY_DEVICE_PASSWORD, } } + +DUMMY_PLUG_DEVICE = SwitcherPowerPlug( + DeviceType.POWER_PLUG, + DeviceState.ON, + DUMMY_DEVICE_ID1, + DUMMY_IP_ADDRESS1, + DUMMY_MAC_ADDRESS1, + DUMMY_DEVICE_NAME1, + DUMMY_POWER_CONSUMPTION1, + DUMMY_ELECTRIC_CURRENT1, +) + +DUMMY_WATER_HEATER_DEVICE = SwitcherWaterHeater( + DeviceType.V4, + DeviceState.ON, + DUMMY_DEVICE_ID2, + DUMMY_IP_ADDRESS2, + DUMMY_MAC_ADDRESS2, + DUMMY_DEVICE_NAME2, + DUMMY_POWER_CONSUMPTION2, + DUMMY_ELECTRIC_CURRENT2, + DUMMY_REMAINING_TIME, + DUMMY_AUTO_SHUT_DOWN, +) + +DUMMY_SWITCHER_DEVICES = [DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE] diff --git a/tests/components/switcher_kis/test_config_flow.py b/tests/components/switcher_kis/test_config_flow.py new file mode 100644 index 00000000000..07a2396a0d9 --- /dev/null +++ b/tests/components/switcher_kis/test_config_flow.py @@ -0,0 +1,106 @@ +"""Test the Switcher config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.switcher_kis.const import DATA_DISCOVERY, DOMAIN +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE + +from tests.common import MockConfigEntry + + +async def test_import(hass): + """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"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Switcher" + assert result["data"] == {} + + +@pytest.mark.parametrize( + "mock_bridge", + [ + [ + DUMMY_PLUG_DEVICE, + DUMMY_WATER_HEATER_DEVICE, + # Make sure we don't detect the same device twice + DUMMY_WATER_HEATER_DEVICE, + ] + ], + indirect=True, +) +async def test_user_setup(hass, mock_bridge): + """Test we can finish a config flow.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 2 + + assert result["type"] == RESULT_TYPE_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"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Switcher" + assert result2["result"].data == {} + + +async def test_user_setup_abort_no_devices_found(hass, mock_bridge): + """Test we abort a config flow if no devices found.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN][DATA_DISCOVERY].result()) == 0 + + assert result["type"] == RESULT_TYPE_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"] == RESULT_TYPE_ABORT + assert result2["reason"] == "no_devices_found" + + +@pytest.mark.parametrize( + "source", + [ + config_entries.SOURCE_IMPORT, + config_entries.SOURCE_USER, + ], +) +async def test_single_instance(hass, source): + """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} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 14eb2a1a16e..367d215862e 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -1,200 +1,111 @@ """Test cases for the switcher_kis component.""" from datetime import timedelta -from typing import Any, Generator from unittest.mock import patch -from aioswitcher.consts import COMMAND_ON -from aioswitcher.devices import SwitcherV2Device -from pytest import raises +import pytest -from homeassistant.components.switcher_kis import ( +from homeassistant import config_entries +from homeassistant.components.switcher_kis.const import ( DATA_DEVICE, DOMAIN, - SIGNAL_SWITCHER_DEVICE_UPDATE, + MAX_UPDATE_INTERVAL_SEC, ) -from homeassistant.components.switcher_kis.switch import ( - CONF_AUTO_OFF, - CONF_TIMER_MINUTES, - SERVICE_SET_AUTO_OFF_NAME, - SERVICE_TURN_ON_WITH_TIMER_NAME, -) -from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.exceptions import UnknownUser -from homeassistant.helpers.config_validation import time_period_str -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt, slugify -from .consts import ( - DUMMY_AUTO_OFF_SET, - DUMMY_DEVICE_ID, - DUMMY_DEVICE_NAME, - DUMMY_DEVICE_STATE, - DUMMY_ELECTRIC_CURRENT, - DUMMY_IP_ADDRESS, - DUMMY_MAC_ADDRESS, - DUMMY_PHONE_ID, - DUMMY_POWER_CONSUMPTION, - DUMMY_REMAINING_TIME, - DUMMY_TIMER_MINUTES_SET, - MANDATORY_CONFIGURATION, - SWITCH_ENTITY_ID, -) +from . import init_integration +from .consts import DUMMY_SWITCHER_DEVICES, YAML_CONFIG -from tests.common import MockUser, async_fire_time_changed +from tests.common import async_fire_time_changed -async def test_failed_config( - hass: HomeAssistant, mock_failed_bridge: Generator[None, Any, None] -) -> None: - """Test failed configuration.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) is False - - -async def test_minimal_config( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test setup with configuration minimal entries.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - - -async def test_discovery_data_bucket( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test the event send with the updated device.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_async_setup_yaml_config(hass, 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() - device = hass.data[DOMAIN].get(DATA_DEVICE) - assert device.device_id == DUMMY_DEVICE_ID - assert device.ip_addr == DUMMY_IP_ADDRESS - assert device.mac_addr == DUMMY_MAC_ADDRESS - assert device.name == DUMMY_DEVICE_NAME - assert device.state == DUMMY_DEVICE_STATE - assert device.remaining_time == DUMMY_REMAINING_TIME - assert device.auto_off_set == DUMMY_AUTO_OFF_SET - assert device.power_consumption == DUMMY_POWER_CONSUMPTION - assert device.electric_current == DUMMY_ELECTRIC_CURRENT - assert device.phone_id == DUMMY_PHONE_ID + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 -async def test_set_auto_off_service( - hass: HomeAssistant, - mock_bridge: Generator[None, Any, None], - mock_api: Generator[None, Any, None], - hass_owner_user: MockUser, -) -> None: - """Test the set_auto_off service.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_async_setup_user_config_flow(hass, mock_bridge) -> None: + """Test setup started by user config flow.""" + with patch("homeassistant.components.switcher_kis.utils.asyncio.sleep"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure(result["flow_id"], {}) await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_SET_AUTO_OFF_NAME) + assert mock_bridge.is_running is True + assert len(hass.data[DOMAIN]) == 2 + assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - context=Context(user_id=hass_owner_user.id), + +async def test_update_fail(hass, mock_bridge, caplog): + """Test entities state unavailable when updates fail..""" + 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 + + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) + ) + await hass.async_block_till_done() + + for device in DUMMY_SWITCHER_DEVICES: + assert ( + f"Device {device.name} did not send update for {MAX_UPDATE_INTERVAL_SEC} seconds" + in caplog.text + ) + + entity_id = f"switch.{slugify(device.name)}" + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + entity_id = f"sensor.{slugify(device.name)}_power_consumption" + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) + await hass.async_block_till_done() + async_fire_time_changed( + hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) ) - with raises(UnknownUser) as unknown_user_exc: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - blocking=True, - context=Context(user_id="not_real_user"), - ) + for device in DUMMY_SWITCHER_DEVICES: + entity_id = f"switch.{slugify(device.name)}" + state = hass.states.get(entity_id) + assert state.state != STATE_UNAVAILABLE - assert unknown_user_exc.type is UnknownUser - - with patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.set_auto_shutdown" - ) as mock_set_auto_shutdown: - await hass.services.async_call( - DOMAIN, - SERVICE_SET_AUTO_OFF_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, - ) - - await hass.async_block_till_done() - - mock_set_auto_shutdown.assert_called_once_with( - time_period_str(DUMMY_AUTO_OFF_SET) - ) + entity_id = f"sensor.{slugify(device.name)}_power_consumption" + state = hass.states.get(entity_id) + assert state.state != STATE_UNAVAILABLE -async def test_turn_on_with_timer_service( - hass: HomeAssistant, - mock_bridge: Generator[None, Any, None], - mock_api: Generator[None, Any, None], - hass_owner_user: MockUser, -) -> None: - """Test the set_auto_off service.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) +async def test_entry_unload(hass, mock_bridge): + """Test entry unload.""" + entry = await init_integration(hass) + assert mock_bridge + 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 hass.services.has_service(DOMAIN, SERVICE_TURN_ON_WITH_TIMER_NAME) - - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - {CONF_ENTITY_ID: SWITCH_ENTITY_ID, CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET}, - blocking=True, - context=Context(user_id=hass_owner_user.id), - ) - - with raises(UnknownUser) as unknown_user_exc: - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - { - CONF_ENTITY_ID: SWITCH_ENTITY_ID, - CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, - }, - blocking=True, - context=Context(user_id="not_real_user"), - ) - - assert unknown_user_exc.type is UnknownUser - - with patch( - "homeassistant.components.switcher_kis.switch.SwitcherV2Api.control_device" - ) as mock_control_device: - await hass.services.async_call( - DOMAIN, - SERVICE_TURN_ON_WITH_TIMER_NAME, - { - CONF_ENTITY_ID: SWITCH_ENTITY_ID, - CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, - }, - ) - - await hass.async_block_till_done() - - mock_control_device.assert_called_once_with( - COMMAND_ON, int(DUMMY_TIMER_MINUTES_SET) - ) - - -async def test_signal_dispatcher( - hass: HomeAssistant, mock_bridge: Generator[None, Any, None] -) -> None: - """Test signal dispatcher dispatching device updates every 4 seconds.""" - assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION) - - await hass.async_block_till_done() - - @callback - def verify_update_data(device: SwitcherV2Device) -> None: - """Use as callback for signal dispatcher.""" - pass - - async_dispatcher_connect(hass, SIGNAL_SWITCHER_DEVICE_UPDATE, verify_update_data) - - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=5)) + assert entry.state is ConfigEntryState.NOT_LOADED + assert mock_bridge.is_running is False + assert len(hass.data[DOMAIN]) == 0 diff --git a/tests/components/switcher_kis/test_sensor.py b/tests/components/switcher_kis/test_sensor.py new file mode 100644 index 00000000000..b6fc1f2a49e --- /dev/null +++ b/tests/components/switcher_kis/test_sensor.py @@ -0,0 +1,93 @@ +"""Test the Switcher Sensor Platform.""" +import pytest + +from homeassistant.components.switcher_kis.const import DATA_DEVICE, DOMAIN +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_PLUG_DEVICE, DUMMY_SWITCHER_DEVICES, DUMMY_WATER_HEATER_DEVICE + +DEVICE_SENSORS_TUPLE = ( + ( + DUMMY_PLUG_DEVICE, + [ + "power_consumption", + "electric_current", + ], + ), + ( + DUMMY_WATER_HEATER_DEVICE, + [ + "power_consumption", + "electric_current", + "remaining_time", + ], + ), +) + + +@pytest.mark.parametrize("mock_bridge", [DUMMY_SWITCHER_DEVICES], indirect=True) +async def test_sensor_platform(hass, mock_bridge): + """Test sensor platform.""" + 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 + + for device, sensors in DEVICE_SENSORS_TUPLE: + for sensor in sensors: + entity_id = f"sensor.{slugify(device.name)}_{sensor}" + state = hass.states.get(entity_id) + assert state.state == str(getattr(device, sensor)) + + +async def test_sensor_disabled(hass, mock_bridge): + """Test sensor disabled by default.""" + await init_integration(hass) + assert mock_bridge + + 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) + + assert entry + assert entry.unique_id == unique_id + assert entry.disabled is True + assert entry.disabled_by == er.DISABLED_INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity( + entry.entity_id, **{"disabled_by": None} + ) + + assert updated_entry != entry + assert updated_entry.disabled is False + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_sensor_update(hass, mock_bridge, monkeypatch): + """Test sensor update.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + sensor = "power_consumption" + entity_id = f"sensor.{slugify(device.name)}_{sensor}" + + state = hass.states.get(entity_id) + assert state.state == str(getattr(device, sensor)) + + monkeypatch.setattr(device, sensor, 1431) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "1431" diff --git a/tests/components/switcher_kis/test_services.py b/tests/components/switcher_kis/test_services.py new file mode 100644 index 00000000000..9b0fcee27df --- /dev/null +++ b/tests/components/switcher_kis/test_services.py @@ -0,0 +1,162 @@ +"""Test the services for the Switcher integration.""" +from unittest.mock import patch + +from aioswitcher.api import Command +from aioswitcher.device import DeviceState +import pytest + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.switcher_kis.const import ( + CONF_AUTO_OFF, + CONF_TIMER_MINUTES, + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + SERVICE_TURN_ON_WITH_TIMER_NAME, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers.config_validation import time_period_str +from homeassistant.util import slugify + +from . import init_integration +from .consts import ( + DUMMY_AUTO_OFF_SET, + DUMMY_PLUG_DEVICE, + DUMMY_TIMER_MINUTES_SET, + DUMMY_WATER_HEATER_DEVICE, +) + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_turn_on_with_timer_service(hass, mock_bridge, mock_api, monkeypatch): + """Test the turn on with timer service.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + ) as mock_control_device: + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON_WITH_TIMER_NAME, + { + ATTR_ENTITY_ID: entity_id, + CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, + }, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with( + Command.ON, int(DUMMY_TIMER_MINUTES_SET) + ) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_set_auto_off_service(hass, mock_bridge, mock_api): + """Test the set auto off service.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown" + ) as mock_set_auto_shutdown: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_set_auto_shutdown.assert_called_once_with( + time_period_str(DUMMY_AUTO_OFF_SET) + ) + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_set_auto_off_service_fail(hass, mock_bridge, mock_api, caplog): + """Test set auto off service failed.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.set_auto_shutdown", + return_value=None, + ) as mock_set_auto_shutdown: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 2 + mock_set_auto_shutdown.assert_called_once_with( + time_period_str(DUMMY_AUTO_OFF_SET) + ) + assert ( + f"Call api for {device.name} failed, api: 'set_auto_shutdown'" + in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) +async def test_plug_unsupported_services(hass, mock_bridge, mock_api, caplog): + """Test plug device unsupported services.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_PLUG_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Turn on with timer + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON_WITH_TIMER_NAME, + { + ATTR_ENTITY_ID: entity_id, + CONF_TIMER_MINUTES: DUMMY_TIMER_MINUTES_SET, + }, + blocking=True, + ) + + assert mock_api.call_count == 0 + assert ( + f"Service '{SERVICE_TURN_ON_WITH_TIMER_NAME}' is not supported by {device.name}" + in caplog.text + ) + + # Auto off + await hass.services.async_call( + DOMAIN, + SERVICE_SET_AUTO_OFF_NAME, + {ATTR_ENTITY_ID: entity_id, CONF_AUTO_OFF: DUMMY_AUTO_OFF_SET}, + blocking=True, + ) + + assert mock_api.call_count == 0 + assert ( + f"Service '{SERVICE_SET_AUTO_OFF_NAME}' is not supported by {device.name}" + in caplog.text + ) diff --git a/tests/components/switcher_kis/test_switch.py b/tests/components/switcher_kis/test_switch.py new file mode 100644 index 00000000000..a44e0c79611 --- /dev/null +++ b/tests/components/switcher_kis/test_switch.py @@ -0,0 +1,127 @@ +"""Test the Switcher switch platform.""" +from unittest.mock import patch + +from aioswitcher.api import Command, SwitcherBaseResponse +from aioswitcher.device import DeviceState +import pytest + +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, + STATE_UNAVAILABLE, +) +from homeassistant.util import slugify + +from . import init_integration +from .consts import DUMMY_PLUG_DEVICE, DUMMY_WATER_HEATER_DEVICE + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_WATER_HEATER_DEVICE]], indirect=True) +async def test_switch(hass, mock_bridge, mock_api, monkeypatch): + """Test the switch.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_WATER_HEATER_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - on + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test state change on --> off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_WATER_HEATER_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test turning on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.ON) + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + # Test turning off + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device" + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(Command.OFF) + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize("mock_bridge", [[DUMMY_PLUG_DEVICE]], indirect=True) +async def test_switch_control_fail(hass, mock_bridge, mock_api, monkeypatch, caplog): + """Test switch control fail.""" + await init_integration(hass) + assert mock_bridge + + device = DUMMY_PLUG_DEVICE + entity_id = f"{SWITCH_DOMAIN}.{slugify(device.name)}" + + # Test initial state - off + monkeypatch.setattr(device, "device_state", DeviceState.OFF) + mock_bridge.mock_callbacks([DUMMY_PLUG_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test exception during turn on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + side_effect=RuntimeError("fake error"), + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 2 + mock_control_device.assert_called_once_with(Command.ON) + assert ( + f"Call api for {device.name} failed, api: 'control_device'" in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + + # Make device available again + mock_bridge.mock_callbacks([DUMMY_PLUG_DEVICE]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + # Test error response during turn on + with patch( + "homeassistant.components.switcher_kis.switch.SwitcherApi.control_device", + return_value=SwitcherBaseResponse(None), + ) as mock_control_device: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + + assert mock_api.call_count == 4 + mock_control_device.assert_called_once_with(Command.ON) + assert ( + f"Call api for {device.name} failed, api: 'control_device'" in caplog.text + ) + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 9c89ec64666..cf043c2ce5f 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -23,7 +23,7 @@ from homeassistant.components.synology_dsm.const import ( DEFAULT_VERIFY_SSL, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_SSDP, SOURCE_USER from homeassistant.const import ( CONF_DISKS, CONF_HOST, @@ -41,7 +41,6 @@ from homeassistant.core import HomeAssistant from .consts import ( DEVICE_TOKEN, HOST, - HOST_2, MACS, PASSWORD, PORT, @@ -256,57 +255,54 @@ async def test_user_vdsm(hass: HomeAssistant, service_vdsm: MagicMock): assert result["data"].get(CONF_VOLUMES) is None -async def test_import(hass: HomeAssistant, service: MagicMock): - """Test import step.""" - # import with minimum setup - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == SERIAL - assert result["title"] == HOST - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT_SSL - assert result["data"][CONF_SSL] == DEFAULT_USE_SSL - assert result["data"][CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_MAC] == MACS - assert result["data"].get("device_token") is None - assert result["data"].get(CONF_DISKS) is None - assert result["data"].get(CONF_VOLUMES) is None - - service.return_value.information.serial = SERIAL_2 - # import with all - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, +async def test_reauth(hass: HomeAssistant, service: MagicMock): + """Test reauthentication.""" + MockConfigEntry( + domain=DOMAIN, data={ - CONF_HOST: HOST_2, - CONF_PORT: PORT, - CONF_SSL: USE_SSL, - CONF_VERIFY_SSL: VERIFY_SSL, + CONF_HOST: HOST, CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_DISKS: ["sda", "sdb", "sdc"], - CONF_VOLUMES: ["volume_1"], + CONF_PASSWORD: f"{PASSWORD}_invalid", }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == SERIAL_2 - assert result["title"] == HOST_2 - assert result["data"][CONF_HOST] == HOST_2 - assert result["data"][CONF_PORT] == PORT - assert result["data"][CONF_SSL] == USE_SSL - assert result["data"][CONF_VERIFY_SSL] == VERIFY_SSL - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_MAC] == MACS - assert result["data"].get("device_token") is None - assert result["data"][CONF_DISKS] == ["sda", "sdb", "sdc"] - assert result["data"][CONF_VOLUMES] == ["volume_1"] + unique_id=SERIAL, + ).add_to_hass(hass) + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=True, + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "data": { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + }, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): @@ -317,15 +313,6 @@ async def test_abort_if_already_setup(hass: HomeAssistant, service: MagicMock): unique_id=SERIAL, ).add_to_hass(hass) - # Should fail, same HOST:PORT (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_HOST: HOST, CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - # Should fail, same HOST:PORT (flow) result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 891296d97ea..4d6708a2e79 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -2,7 +2,9 @@ from unittest.mock import patch import pytest +from synology_dsm.exceptions import SynologyDSMLoginInvalidException +from homeassistant import data_entry_flow from homeassistant.components.synology_dsm.const import DOMAIN, SERVICES from homeassistant.const import ( CONF_HOST, @@ -40,3 +42,29 @@ async def test_services_registered(hass: HomeAssistant): assert await hass.config_entries.async_setup(entry.entry_id) for service in SERVICES: assert hass.services.has_service(DOMAIN, service) + + +@pytest.mark.no_bypass_setup +async def test_reauth_triggered(hass: HomeAssistant): + """Test if reauthentication flow is triggered.""" + with patch( + "homeassistant.components.synology_dsm.SynoApi.async_setup", + side_effect=SynologyDSMLoginInvalidException(USERNAME), + ), patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", + return_value={"type": data_entry_flow.RESULT_TYPE_FORM}, + ) as mock_async_step_reauth: + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_MAC: MACS[0], + }, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + mock_async_step_reauth.assert_called_once() diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py index 9f199f0aa66..767d6b9cfcf 100644 --- a/tests/components/tasmota/test_config_flow.py +++ b/tests/components/tasmota/test_config_flow.py @@ -1,6 +1,6 @@ """Test config flow.""" from homeassistant import config_entries -from homeassistant.components.mqtt.models import Message +from homeassistant.components.mqtt.models import ReceiveMessage from tests.common import MockConfigEntry @@ -19,7 +19,9 @@ async def test_mqtt_abort_if_existing_entry(hass, mqtt_mock): async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): """Check MQTT flow aborts if discovery topic is invalid.""" - discovery_info = Message("", "", 0, False, subscribed_topic="custom_prefix/##") + discovery_info = ReceiveMessage( + "", "", 0, False, subscribed_topic="custom_prefix/##" + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) @@ -29,7 +31,9 @@ async def test_mqtt_abort_invalid_topic(hass, mqtt_mock): async def test_mqtt_setup(hass, mqtt_mock) -> None: """Test we can finish a config flow through MQTT with custom prefix.""" - discovery_info = Message("", "", 0, False, subscribed_topic="custom_prefix/123/#") + discovery_info = ReceiveMessage( + "", "", 0, False, subscribed_topic="custom_prefix/123/#" + ) result = await hass.config_entries.flow.async_init( "tasmota", context={"source": config_entries.SOURCE_MQTT}, data=discovery_info ) diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index e1ba2615742..411567208db 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -384,7 +384,7 @@ async def test_controlling_state_via_mqtt_ct(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( @@ -402,7 +402,7 @@ async def test_controlling_state_via_mqtt_ct(hass, mqtt_mock, setup_tasmota): state = hass.states.get("light.test") assert state.state == STATE_ON assert state.attributes.get("color_temp") == 300 - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" @@ -446,7 +446,7 @@ async def test_controlling_state_via_mqtt_rgbw(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "hs" async_fire_mqtt_message( @@ -454,7 +454,7 @@ async def test_controlling_state_via_mqtt_rgbw(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 191.25 + assert state.attributes.get("brightness") == 191 assert state.attributes.get("color_mode") == "white" async_fire_mqtt_message( @@ -464,7 +464,7 @@ async def test_controlling_state_via_mqtt_rgbw(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -473,7 +473,7 @@ async def test_controlling_state_via_mqtt_rgbw(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") is None assert state.attributes.get("color_mode") == "white" @@ -544,7 +544,7 @@ async def test_controlling_state_via_mqtt_rgbww(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( @@ -645,7 +645,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya(hass, mqtt_mock, setup_tasm ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( @@ -1216,7 +1216,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 # Dim the light from 50->0: Speed should be 6*2*2=24 await common.async_turn_off(hass, "light.test", transition=6) @@ -1254,7 +1254,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("rgb_color") == (0, 255, 0) # Set color of the light from 0,255,0 to 255,0,0 @ 50%: Speed should be 6*2*2=24 @@ -1296,7 +1296,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 153 # Set color_temp of the light from 153 to 500 @ 50%: Speed should be 6*2*2=24 @@ -1315,7 +1315,7 @@ async def test_transition(hass, mqtt_mock, setup_tasmota): ) state = hass.states.get("light.test") assert state.state == STATE_ON - assert state.attributes.get("brightness") == 127.5 + assert state.attributes.get("brightness") == 128 assert state.attributes.get("color_temp") == 500 # Set color_temp of the light from 500 to 326 @ 50%: Speed should be 6*2*2*2=48->40 diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index c1309a16e67..e2b65abcf25 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -34,7 +34,7 @@ def calls_fixture(hass): return async_mock_service(hass, "test", "automation") -async def test_template_state_text(hass, calls): +async def test_template_state_text(hass, calls, caplog): """Test the state text of a template.""" with assert_setup_component(1, "cover"): assert await setup.async_setup_component( @@ -64,30 +64,147 @@ async def test_template_state_text(hass, calls): await hass.async_start() await hass.async_block_till_done() - state = hass.states.async_set("cover.test_state", STATE_OPEN) + hass.states.async_set("cover.test_state", STATE_OPEN) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPEN - state = hass.states.async_set("cover.test_state", STATE_CLOSED) + hass.states.async_set("cover.test_state", STATE_CLOSED) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_CLOSED - state = hass.states.async_set("cover.test_state", STATE_OPENING) + hass.states.async_set("cover.test_state", STATE_OPENING) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_OPENING - state = hass.states.async_set("cover.test_state", STATE_CLOSING) + hass.states.async_set("cover.test_state", STATE_CLOSING) await hass.async_block_till_done() - state = hass.states.get("cover.test_template_cover") assert state.state == STATE_CLOSING + # Unknown state sets position to None - "closing" takes precedence + state = hass.states.async_set("cover.test_state", "dog") + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_CLOSING + assert "Received invalid cover is_on state: dog" in caplog.text + + # Set state to open + hass.states.async_set("cover.test_state", STATE_OPEN) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + + # Unknown state sets position to None -> Open + state = hass.states.async_set("cover.test_state", "cat") + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + assert "Received invalid cover is_on state: cat" in caplog.text + + # Set state to closed + hass.states.async_set("cover.test_state", STATE_CLOSED) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_CLOSED + + # Unknown state sets position to None -> Open + state = hass.states.async_set("cover.test_state", "bear") + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + assert "Received invalid cover is_on state: bear" in caplog.text + + +async def test_template_state_text_combined(hass, calls, caplog): + """Test the state text of a template which combines position and value templates.""" + with assert_setup_component(1, "cover"): + assert await setup.async_setup_component( + hass, + "cover", + { + "cover": { + "platform": "template", + "covers": { + "test_template_cover": { + "position_template": "{{ states.cover.test.attributes.position }}", + "value_template": "{{ states.cover.test_state.state }}", + "open_cover": { + "service": "cover.open_cover", + "entity_id": "cover.test_state", + }, + "close_cover": { + "service": "cover.close_cover", + "entity_id": "cover.test_state", + }, + } + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # Test default state + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + + # Change to "open" should be ignored + state = hass.states.async_set("cover.test_state", STATE_OPEN) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + + # Change to "closed" should be ignored + state = hass.states.async_set("cover.test_state", STATE_CLOSED) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + + # Change to "opening" should be accepted + state = hass.states.async_set("cover.test_state", STATE_OPENING) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPENING + + # Change to "closing" should be accepted + state = hass.states.async_set("cover.test_state", STATE_CLOSING) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_CLOSING + + # Set position to 0=closed + hass.states.async_set("cover.test", STATE_CLOSED, attributes={"position": 0}) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_CLOSING + assert state.attributes["current_position"] == 0 + + # Clear "closing" state, STATE_OPEN will be ignored and state derived from position + state = hass.states.async_set("cover.test_state", STATE_OPEN) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_CLOSED + + # Set position to 10 + hass.states.async_set("cover.test", STATE_CLOSED, attributes={"position": 10}) + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + assert state.attributes["current_position"] == 10 + + # Unknown state should be ignored + state = hass.states.async_set("cover.test_state", "dog") + await hass.async_block_till_done() + state = hass.states.get("cover.test_template_cover") + assert state.state == STATE_OPEN + assert state.attributes["current_position"] == 10 + assert "Received invalid cover is_on state: dog" in caplog.text + async def test_template_state_boolean(hass, calls): """Test the value_template attribute.""" @@ -250,43 +367,6 @@ async def test_template_out_of_bounds(hass, calls): assert state.attributes.get("current_position") is None -async def test_template_mutex(hass, calls): - """Test that only value or position template can be used.""" - with assert_setup_component(0, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": { - "value_template": "{{ 1 == 1 }}", - "position_template": "{{ 42 }}", - "open_cover": { - "service": "cover.open_cover", - "entity_id": "cover.test_state", - }, - "close_cover": { - "service": "cover.close_cover", - "entity_id": "cover.test_state", - }, - "icon_template": "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}", - } - }, - } - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert hass.states.async_all() == [] - - async def test_template_open_or_position(hass, caplog): """Test that at least one of open_cover or set_position is used.""" assert await setup.async_setup_component( diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index a00ca3b7e91..2cbdf23190d 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -325,6 +325,84 @@ async def test_unlock_action(hass, calls): assert len(calls) == 1 +async def test_unlocking(hass, calls): + """Test unlocking.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_UNLOCKING) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_UNLOCKING + + +async def test_locking(hass, calls): + """Test unlocking.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_LOCKING) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_LOCKING + + +async def test_jammed(hass, calls): + """Test jammed.""" + assert await setup.async_setup_component( + hass, + lock.DOMAIN, + { + "lock": { + "platform": "template", + "value_template": "{{ states.input_select.test_state.state }}", + "lock": {"service": "test.automation"}, + "unlock": {"service": "test.automation"}, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("input_select.test_state", lock.STATE_JAMMED) + await hass.async_block_till_done() + + state = hass.states.get("lock.template_lock") + assert state.state == lock.STATE_JAMMED + + async def test_available_template_with_entities(hass): """Test availability templates with values from other entities.""" diff --git a/tests/components/tplink/consts.py b/tests/components/tplink/consts.py new file mode 100644 index 00000000000..e579be61df2 --- /dev/null +++ b/tests/components/tplink/consts.py @@ -0,0 +1,116 @@ +"""Constants for the TP-Link component tests.""" + +SMARTPLUG_HS110_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS110(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM:ENE", + "mac": "69:F2:3C:8E:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", + "next_action": {"type": -1}, + "err_code": 0, + }, + "realtime": { + "voltage_mv": 233957, + "current_ma": 21, + "power_mw": 0, + "total_wh": 1793, + "err_code": 0, + }, +} +SMARTPLUG_HS100_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "hw_ver": "4.0", + "model": "HS100(EU)", + "deviceId": "4C56447B395BB7A2FAC68C9DFEE2E84163222581", + "oemId": "40F54B43071E9436B6395611E9D91CEA", + "hwId": "A6C77E4FDD238B53D824AC8DA361F043", + "rssi": -24, + "longitude_i": 130793, + "latitude_i": 480582, + "alias": "SmartPlug", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM:", + "mac": "A9:F4:3D:A4:E3:47", + "updating": 0, + "led_off": 0, + "relay_state": 0, + "on_time": 0, + "active_mode": "none", + "icon_hash": "", + "dev_name": "Smart Wi-Fi Plug", + "next_action": {"type": -1}, + "err_code": 0, + } +} +SMARTSTRIP_KP303_DATA = { + "sysinfo": { + "sw_ver": "1.0.4 Build 210428 Rel.135415", + "hw_ver": "1.0", + "model": "KP303(AU)", + "deviceId": "03102547AB1A57A4E4AA5B4EFE34C3005726B97D", + "oemId": "1F950FC9BFF278D9D35E046C129D9411", + "hwId": "9E86D4F840D2787D3D7A6523A731BA2C", + "rssi": -74, + "longitude_i": 1158985, + "latitude_i": -319172, + "alias": "TP-LINK_Power Strip_00B1", + "status": "new", + "mic_type": "IOT.SMARTPLUGSWITCH", + "feature": "TIM", + "mac": "D4:DD:D6:95:B0:F9", + "updating": 0, + "led_off": 0, + "children": [ + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913000", + "state": 0, + "alias": "R-Plug 1", + "on_time": 0, + "next_action": {"type": -1}, + }, + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913001", + "state": 1, + "alias": "R-Plug 2", + "on_time": 93835, + "next_action": {"type": -1}, + }, + { + "id": "8006B399B7FE68D4E6991CCCEA239C081DFA913002", + "state": 1, + "alias": "R-Plug 3", + "on_time": 93834, + "next_action": {"type": -1}, + }, + ], + "child_num": 3, + "err_code": 0, + }, + "realtime": { + "voltage_mv": 233957, + "current_ma": 21, + "power_mw": 0, + "total_wh": 1793, + "err_code": 0, + }, + "context": "1", +} diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 49309a6ecef..d96d6846939 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -1,24 +1,40 @@ """Tests for the TP-Link component.""" from __future__ import annotations +import time from typing import Any from unittest.mock import MagicMock, patch -from pyHS100 import SmartBulb, SmartDevice, SmartDeviceException, SmartPlug +from pyHS100 import SmartBulb, SmartDevice, SmartDeviceException, SmartPlug, smartstrip +from pyHS100.smartdevice import EmeterStatus import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components import tplink -from homeassistant.components.tplink.common import ( +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.tplink.common import SmartDevices +from homeassistant.components.tplink.const import ( CONF_DIMMER, CONF_DISCOVERY, CONF_LIGHT, + CONF_SW_VERSION, CONF_SWITCH, + UNAVAILABLE_RETRY_DELAY, ) +from homeassistant.components.tplink.sensor import ENERGY_SENSORS from homeassistant.const import CONF_HOST +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, slugify -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry, async_fire_time_changed, mock_coro +from tests.components.tplink.consts import ( + SMARTPLUG_HS100_DATA, + SMARTPLUG_HS110_DATA, + SMARTSTRIP_KP303_DATA, +) async def test_creating_entry_tries_discover(hass): @@ -186,7 +202,7 @@ async def test_configuring_discovery_disabled(hass): assert mock_setup.call_count == 1 -async def test_platforms_are_initialized(hass): +async def test_platforms_are_initialized(hass: HomeAssistant): """Test that platforms are initialized per configuration array.""" config = { tplink.DOMAIN: { @@ -196,26 +212,119 @@ async def test_platforms_are_initialized(hass): } } + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", + False, + ): + + light = SmartBulb("123.123.123.123") + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) + switch.get_emeter_realtime = MagicMock( + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) + ) + switch.get_emeter_daily = MagicMock( + return_value={int(time.strftime("%e")): 1.123} + ) + get_static_devices.return_value = SmartDevices([light], [switch]) + + # patching is_dimmable is necessray to avoid misdetection as light. + await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get(f"switch.{switch.alias}") + assert state + assert state.name == switch.alias + + for description in ENERGY_SENSORS: + state = hass.states.get( + f"sensor.{switch.alias}_{slugify(description.name)}" + ) + assert state + assert state.state is not None + assert state.name == f"{switch.alias} {description.name}" + + device_registry = dr.async_get(hass) + assert len(device_registry.devices) == 1 + device = next(iter(device_registry.devices.values())) + assert device.name == switch.alias + assert device.model == switch.model + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, switch.mac.lower())} + assert device.sw_version == switch.sys_info[CONF_SW_VERSION] + + +async def test_smartplug_without_consumption_sensors(hass: HomeAssistant): + """Test that platforms are initialized per configuration array.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], + } + } + + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + ): + + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) + get_static_devices.return_value = SmartDevices([], [switch]) + + await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 + + entities = hass.states.async_entity_ids(SENSOR_DOMAIN) + assert len(entities) == 0 + + +async def test_smartstrip_device(hass: HomeAssistant): + """Test discover a SmartStrip devices.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: True, + } + } + + class SmartStrip(smartstrip.SmartStrip): + """Moked SmartStrip class.""" + + def get_sysinfo(self): + return SMARTSTRIP_KP303_DATA["sysinfo"] + with patch( "homeassistant.components.tplink.common.Discover.discover" ) as discover, patch( "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( - "homeassistant.components.tplink.light.async_setup_entry", - return_value=mock_coro(True), - ) as light_setup, patch( - "homeassistant.components.tplink.switch.async_setup_entry", - return_value=mock_coro(True), - ) as switch_setup, patch( - "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + "homeassistant.components.tplink.common.SmartPlug.get_sysinfo", + return_value=SMARTSTRIP_KP303_DATA["sysinfo"], ): - # patching is_dimmable is necessray to avoid misdetection as light. - await async_setup_component(hass, tplink.DOMAIN, config) + + strip = SmartStrip("123.123.123.123") + discover.return_value = {"123.123.123.123": strip} + + assert await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert discover.call_count == 0 - assert light_setup.call_count == 1 - assert switch_setup.call_count == 1 + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 3 async def test_no_config_creates_no_entry(hass): @@ -230,6 +339,65 @@ async def test_no_config_creates_no_entry(hass): assert mock_setup.call_count == 0 +async def test_not_available_at_startup(hass: HomeAssistant): + """Test when configured devices are not available.""" + config = { + tplink.DOMAIN: { + CONF_DISCOVERY: False, + CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}], + } + } + + with patch("homeassistant.components.tplink.common.Discover.discover"), patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( + "homeassistant.components.tplink.common.SmartDevice._query_helper" + ), patch( + "homeassistant.components.tplink.light.async_setup_entry", + return_value=mock_coro(True), + ), patch( + "homeassistant.components.tplink.common.SmartPlug.is_dimmable", False + ): + + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(side_effect=SmartDeviceException()) + get_static_devices.return_value = SmartDevices([], [switch]) + + # run setup while device unreachable + await async_setup_component(hass, tplink.DOMAIN, config) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with still unreachable device + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 0 + + # retrying with now reachable device + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS100_DATA["sysinfo"]) + async_fire_time_changed(hass, dt.utcnow() + UNAVAILABLE_RETRY_DELAY) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(tplink.DOMAIN) + assert len(entries) == 1 + assert entries[0].state is config_entries.ConfigEntryState.LOADED + + entities = hass.states.async_entity_ids(SWITCH_DOMAIN) + assert len(entities) == 1 + + @pytest.mark.parametrize("platform", ["switch", "light"]) async def test_unload(hass, platform): """Test that the async_unload_entry works.""" @@ -238,21 +406,35 @@ async def test_unload(hass, platform): entry.add_to_hass(hass) with patch( + "homeassistant.components.tplink.get_static_devices" + ) as get_static_devices, patch( "homeassistant.components.tplink.common.SmartDevice._query_helper" ), patch( f"homeassistant.components.tplink.{platform}.async_setup_entry", return_value=mock_coro(True), - ) as light_setup: + ) as async_setup_entry: config = { tplink.DOMAIN: { platform: [{CONF_HOST: "123.123.123.123"}], CONF_DISCOVERY: False, } } + + light = SmartBulb("123.123.123.123") + switch = SmartPlug("321.321.321.321") + switch.get_sysinfo = MagicMock(return_value=SMARTPLUG_HS110_DATA["sysinfo"]) + switch.get_emeter_realtime = MagicMock( + return_value=EmeterStatus(SMARTPLUG_HS110_DATA["realtime"]) + ) + if platform == "light": + get_static_devices.return_value = SmartDevices([light], []) + elif platform == "switch": + get_static_devices.return_value = SmartDevices([], [switch]) + assert await async_setup_component(hass, tplink.DOMAIN, config) await hass.async_block_till_done() - assert len(light_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 assert tplink.DOMAIN in hass.data assert await tplink.async_unload_entry(hass, entry) diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index ea8809bc679..c9b07529ea4 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.tplink.common import ( +from homeassistant.components.tplink.const import ( CONF_DIMMER, CONF_DISCOVERY, CONF_LIGHT, diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 6a911f8d4db..6e546be93f3 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch +from urllib.parse import urlparse from homeassistant import config_entries, data_entry_flow from homeassistant.components import ssdp @@ -33,7 +34,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_flow_ssdp_discovery(hass: HomeAssistant): """Test config flow: discovered + configured through ssdp.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) ssdp_discoveries = [ { @@ -93,7 +94,7 @@ async def test_flow_ssdp_discovery(hass: HomeAssistant): async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): """Test config flow: incomplete discovery through ssdp.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) # Discovered via step ssdp. @@ -112,9 +113,9 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): - """Test config flow: discovery through ssdp, but ignored.""" + """Test config flow: discovery through ssdp, but ignored, as hostname is used by existing config entry.""" udn = "uuid:device_random_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) # Existing entry. @@ -123,46 +124,31 @@ async def test_flow_ssdp_discovery_ignored(hass: HomeAssistant): data={ CONFIG_ENTRY_UDN: "uuid:device_random_2", CONFIG_ENTRY_ST: mock_device.device_type, - CONFIG_ENTRY_HOSTNAME: mock_device.hostname, + CONFIG_ENTRY_HOSTNAME: urlparse(location).hostname, }, options={CONFIG_ENTRY_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) config_entry.add_to_hass(hass) - discoveries = [ - { - DISCOVERY_LOCATION: location, - DISCOVERY_NAME: mock_device.name, - DISCOVERY_ST: mock_device.device_type, - DISCOVERY_UDN: mock_device.udn, - DISCOVERY_UNIQUE_ID: mock_device.unique_id, - DISCOVERY_USN: mock_device.usn, - DISCOVERY_HOSTNAME: mock_device.hostname, - } - ] - - with patch.object( - Device, "async_supplement_discovery", AsyncMock(return_value=discoveries[0]) - ): - # Discovered via step ssdp, but ignored. - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data={ - ssdp.ATTR_SSDP_LOCATION: location, - ssdp.ATTR_SSDP_ST: mock_device.device_type, - ssdp.ATTR_SSDP_USN: mock_device.usn, - ssdp.ATTR_UPNP_UDN: mock_device.udn, - }, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "discovery_ignored" + # Discovered via step ssdp, but ignored. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_SSDP_LOCATION: location, + ssdp.ATTR_SSDP_ST: mock_device.device_type, + ssdp.ATTR_SSDP_USN: mock_device.usn, + ssdp.ATTR_UPNP_UDN: mock_device.udn, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "discovery_ignored" async def test_flow_user(hass: HomeAssistant): """Test config flow: discovered + configured through user.""" udn = "uuid:device_1" - location = "dummy" + location = "http://dummy" mock_device = MockDevice(udn) ssdp_discoveries = [ { @@ -217,7 +203,7 @@ async def test_flow_import(hass: HomeAssistant): """Test config flow: discovered + configured through configuration.yaml.""" udn = "uuid:device_1" mock_device = MockDevice(udn) - location = "dummy" + location = "http://dummy" ssdp_discoveries = [ { ssdp.ATTR_SSDP_LOCATION: location, diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index f302d0ca5b3..cd68a0b5877 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -33,18 +33,6 @@ async def test_invalid_login_error(hass): assert result["errors"] == {"base": "invalid_auth"} -async def test_config_flow_configuration_yaml(hass): - """Test config flow with configuration.yaml user input.""" - test_dict = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} - flow = config_flow.VeSyncFlowHandler() - flow.hass = hass - with patch("pyvesync.vesync.VeSync.login", return_value=True): - result = await flow.async_step_import(test_dict) - - assert result["data"].get(CONF_USERNAME) == test_dict[CONF_USERNAME] - assert result["data"].get(CONF_PASSWORD) == test_dict[CONF_PASSWORD] - - async def test_config_flow_user_input(hass): """Test config flow with user input.""" flow = config_flow.VeSyncFlowHandler() diff --git a/tests/components/waze_travel_time/conftest.py b/tests/components/waze_travel_time/conftest.py index 237b476aa25..6954522dc85 100644 --- a/tests/components/waze_travel_time/conftest.py +++ b/tests/components/waze_travel_time/conftest.py @@ -5,6 +5,13 @@ from WazeRouteCalculator import WRCError import pytest +@pytest.fixture(autouse=True) +def mock_wrc(): + """Mock out WazeRouteCalculator.""" + with patch("homeassistant.components.waze_travel_time.sensor.WazeRouteCalculator"): + yield + + @pytest.fixture(name="skip_notifications", autouse=True) def skip_notifications_fixture(): """Skip notification calls.""" diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index ba1995e8c83..7766fe512cc 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -19,7 +19,7 @@ MOCK_SERIAL_NUMBER = "WemoSerialNumber" @pytest.fixture(name="pywemo_model") def pywemo_model_fixture(): """Fixture containing a pywemo class name used by pywemo_device_fixture.""" - return "Insight" + return "LightSwitch" @pytest.fixture(name="pywemo_registry") diff --git a/tests/components/wemo/entity_test_helpers.py b/tests/components/wemo/entity_test_helpers.py index 9289d4a0171..3d1a73941e6 100644 --- a/tests/components/wemo/entity_test_helpers.py +++ b/tests/components/wemo/entity_test_helpers.py @@ -7,6 +7,7 @@ import threading from unittest.mock import patch import async_timeout +import pywemo from pywemo.ouimeaux_device.api.service import ActionException from homeassistant.components.homeassistant import ( @@ -139,10 +140,14 @@ async def test_async_update_locked_multiple_callbacks( async def test_async_locked_update_with_exception( - hass, wemo_entity, pywemo_device, update_polling_method=None + hass, + wemo_entity, + pywemo_device, + update_polling_method=None, + expected_state=STATE_OFF, ): """Test that the entity becomes unavailable when communication is lost.""" - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + assert hass.states.get(wemo_entity.entity_id).state == expected_state await async_setup_component(hass, HA_DOMAIN, {}) update_polling_method = update_polling_method or pywemo_device.get_state update_polling_method.side_effect = ActionException @@ -157,9 +162,11 @@ async def test_async_locked_update_with_exception( assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE -async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_device): +async def test_async_update_with_timeout_and_recovery( + hass, wemo_entity, pywemo_device, expected_state=STATE_OFF +): """Test that the entity becomes unavailable after a timeout, and that it recovers.""" - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + assert hass.states.get(wemo_entity.entity_id).state == expected_state await async_setup_component(hass, HA_DOMAIN, {}) event = threading.Event() @@ -170,6 +177,8 @@ async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_ if hasattr(pywemo_device, "bridge_update"): pywemo_device.bridge_update.side_effect = get_state + elif isinstance(pywemo_device, pywemo.Insight): + pywemo_device.update_insight_params.side_effect = get_state else: pywemo_device.get_state.side_effect = get_state timeout = async_timeout.timeout(0) @@ -187,4 +196,4 @@ async def test_async_update_with_timeout_and_recovery(hass, wemo_entity, pywemo_ # Check that the entity recovers and is available after the update succeeds. event.set() await hass.async_block_till_done() - assert hass.states.get(wemo_entity.entity_id).state == STATE_OFF + assert hass.states.get(wemo_entity.entity_id).state == expected_state diff --git a/tests/components/wemo/test_sensor.py b/tests/components/wemo/test_sensor.py new file mode 100644 index 00000000000..3b8786131a7 --- /dev/null +++ b/tests/components/wemo/test_sensor.py @@ -0,0 +1,165 @@ +"""Tests for the Wemo sensor entity.""" + +import pytest + +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.components.wemo import CONF_DISCOVERY, CONF_STATIC +from homeassistant.components.wemo.const import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from . import entity_test_helpers +from .conftest import MOCK_HOST, MOCK_PORT + + +@pytest.fixture +def pywemo_model(): + """Pywemo LightSwitch models use the switch platform.""" + return "Insight" + + +@pytest.fixture(name="pywemo_device") +def pywemo_device_fixture(pywemo_device): + """Fixture for WeMoDevice instances.""" + pywemo_device.insight_params = { + "currentpower": 1.0, + "todaymw": 200000000.0, + "state": 0, + "onfor": 0, + "ontoday": 0, + "ontotal": 0, + "powerthreshold": 0, + } + yield pywemo_device + + +class InsightTestTemplate: + """Base class for testing WeMo Insight Sensors.""" + + ENTITY_ID_SUFFIX: str + EXPECTED_STATE_VALUE: str + INSIGHT_PARAM_NAME: str + + @pytest.fixture(name="wemo_entity") + @classmethod + async def async_wemo_entity_fixture(cls, hass, pywemo_device): + """Fixture for a Wemo entity in hass.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_DISCOVERY: False, + CONF_STATIC: [f"{MOCK_HOST}:{MOCK_PORT}"], + }, + }, + ) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + correct_entity = None + to_remove = [] + for entry in entity_registry.entities.values(): + if entry.entity_id.endswith(cls.ENTITY_ID_SUFFIX): + correct_entity = entry + else: + to_remove.append(entry.entity_id) + + for removal in to_remove: + entity_registry.async_remove(removal) + assert len(entity_registry.entities) == 1 + return correct_entity + + # Tests that are in common among wemo platforms. These test methods will be run + # in the scope of this test module. They will run using the pywemo_model from + # this test module (Insight). + async def test_async_update_locked_multiple_updates( + self, hass, pywemo_registry, wemo_entity, pywemo_device + ): + """Test that two hass async_update state updates do not proceed at the same time.""" + pywemo_device.subscription_update.return_value = False + await entity_test_helpers.test_async_update_locked_multiple_updates( + hass, + pywemo_registry, + wemo_entity, + pywemo_device, + update_polling_method=pywemo_device.update_insight_params, + ) + + async def test_async_update_locked_multiple_callbacks( + self, hass, pywemo_registry, wemo_entity, pywemo_device + ): + """Test that two device callback state updates do not proceed at the same time.""" + pywemo_device.subscription_update.return_value = False + await entity_test_helpers.test_async_update_locked_multiple_callbacks( + hass, + pywemo_registry, + wemo_entity, + pywemo_device, + update_polling_method=pywemo_device.update_insight_params, + ) + + async def test_async_update_locked_callback_and_update( + self, hass, pywemo_registry, wemo_entity, pywemo_device + ): + """Test that a callback and a state update request can't both happen at the same time.""" + pywemo_device.subscription_update.return_value = False + await entity_test_helpers.test_async_update_locked_callback_and_update( + hass, + pywemo_registry, + wemo_entity, + pywemo_device, + update_polling_method=pywemo_device.update_insight_params, + ) + + async def test_async_locked_update_with_exception( + self, hass, wemo_entity, pywemo_device + ): + """Test that the entity becomes unavailable when communication is lost.""" + await entity_test_helpers.test_async_locked_update_with_exception( + hass, + wemo_entity, + pywemo_device, + update_polling_method=pywemo_device.update_insight_params, + expected_state=self.EXPECTED_STATE_VALUE, + ) + + async def test_async_update_with_timeout_and_recovery( + self, hass, wemo_entity, pywemo_device + ): + """Test that the entity becomes unavailable after a timeout, and that it recovers.""" + await entity_test_helpers.test_async_update_with_timeout_and_recovery( + hass, wemo_entity, pywemo_device, expected_state=self.EXPECTED_STATE_VALUE + ) + + async def test_state_unavailable(self, hass, wemo_entity, pywemo_device): + """Test that there is no failure if the insight_params is not populated.""" + del pywemo_device.insight_params[self.INSIGHT_PARAM_NAME] + await async_setup_component(hass, HA_DOMAIN, {}) + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: [wemo_entity.entity_id]}, + blocking=True, + ) + assert hass.states.get(wemo_entity.entity_id).state == STATE_UNAVAILABLE + + +class TestInsightCurrentPower(InsightTestTemplate): + """Test the InsightCurrentPower class.""" + + ENTITY_ID_SUFFIX = "_current_power" + EXPECTED_STATE_VALUE = "0.001" + INSIGHT_PARAM_NAME = "currentpower" + + +class TestInsightTodayEnergy(InsightTestTemplate): + """Test the InsightTodayEnergy class.""" + + ENTITY_ID_SUFFIX = "_today_energy" + EXPECTED_STATE_VALUE = "3.33" + INSIGHT_PARAM_NAME = "todaymw" diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index aea2d0152b2..9e6efeb37bf 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -292,11 +292,9 @@ def get_config_entries_for_user_id( ) -> tuple[ConfigEntry]: """Get a list of config entries that apply to a specific withings user.""" return tuple( - [ - config_entry - for config_entry in hass.config_entries.async_entries(const.DOMAIN) - if config_entry.data.get("token", {}).get("userid") == user_id - ] + config_entry + for config_entry in hass.config_entries.async_entries(const.DOMAIN) + if config_entry.data.get("token", {}).get("userid") == user_id ) diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index d61b675e2f2..2d71126e0be 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( from homeassistant.components.wled.const import ( ATTR_INTENSITY, ATTR_PALETTE, - ATTR_PLAYLIST, ATTR_PRESET, ATTR_REVERSE, ATTR_SPEED, @@ -58,7 +57,6 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" assert state.attributes.get(ATTR_INTENSITY) == 128 assert state.attributes.get(ATTR_PALETTE) == "Default" - assert state.attributes.get(ATTR_PLAYLIST) is None assert state.attributes.get(ATTR_PRESET) is None assert state.attributes.get(ATTR_REVERSE) is False assert state.attributes.get(ATTR_SPEED) == 32 @@ -77,7 +75,6 @@ async def test_rgb_light_state( assert state.attributes.get(ATTR_ICON) == "mdi:led-strip-variant" assert state.attributes.get(ATTR_INTENSITY) == 64 assert state.attributes.get(ATTR_PALETTE) == "Random Cycle" - assert state.attributes.get(ATTR_PLAYLIST) is None assert state.attributes.get(ATTR_PRESET) is None assert state.attributes.get(ATTR_REVERSE) is False assert state.attributes.get(ATTR_SPEED) == 16 @@ -566,7 +563,10 @@ async def test_effect_service_error( async def test_preset_service( - hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the preset service of a WLED light.""" await hass.services.async_call( @@ -595,6 +595,8 @@ async def test_preset_service( assert mock_wled.preset.call_count == 2 mock_wled.preset.assert_called_with(preset=2) + assert "The 'wled.preset' service is deprecated" in caplog.text + async def test_preset_service_error( hass: HomeAssistant, diff --git a/tests/components/wled/test_select.py b/tests/components/wled/test_select.py index abdef0c2ff5..dbc1bf7c970 100644 --- a/tests/components/wled/test_select.py +++ b/tests/components/wled/test_select.py @@ -358,6 +358,126 @@ async def test_preset_select_connection_error( mock_wled.preset.assert_called_with(preset="Preset 2") +async def test_playlist_unavailable_without_playlists( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test WLED playlist entity is unavailable when playlists are not available.""" + state = hass.states.get("select.wled_rgb_light_playlist") + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +async def test_playlist_state( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, +) -> None: + """Test the creation and values of the WLED selects.""" + entity_registry = er.async_get(hass) + + state = hass.states.get("select.wled_rgbw_light_playlist") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:play-speed" + assert state.attributes.get(ATTR_OPTIONS) == ["Playlist 1", "Playlist 2"] + assert state.state == "Playlist 1" + + entry = entity_registry.async_get("select.wled_rgbw_light_playlist") + assert entry + assert entry.unique_id == "aabbccddee11_playlist" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", + ATTR_OPTION: "Playlist 2", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert mock_wled.playlist.call_count == 1 + mock_wled.playlist.assert_called_with(playlist="Playlist 2") + + +@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +async def test_old_style_playlist_active( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when old style playlist cycle is active.""" + # Set device playlist to 0, which meant "cycle" previously. + mock_wled.update.return_value.state.playlist = 0 + + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgbw_light_playlist") + assert state + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +async def test_playlist_select_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.playlist.side_effect = WLEDError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", + ATTR_OPTION: "Playlist 2", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgbw_light_playlist") + assert state + assert state.state == "Playlist 1" + assert "Invalid response from API" in caplog.text + assert mock_wled.playlist.call_count == 1 + mock_wled.playlist.assert_called_with(playlist="Playlist 2") + + +@pytest.mark.parametrize("mock_wled", ["wled/rgbw.json"], indirect=True) +async def test_playlist_select_connection_error( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_wled: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error handling of the WLED selects.""" + mock_wled.playlist.side_effect = WLEDConnectionError + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.wled_rgbw_light_playlist", + ATTR_OPTION: "Playlist 2", + }, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("select.wled_rgbw_light_playlist") + assert state + assert state.state == STATE_UNAVAILABLE + assert "Error communicating with API" in caplog.text + assert mock_wled.playlist.call_count == 1 + mock_wled.playlist.assert_called_with(playlist="Playlist 2") + + @pytest.mark.parametrize( "entity_id", ( diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py index 4f2b07f4f51..e6e4b130d99 100644 --- a/tests/components/wled/test_sensor.py +++ b/tests/components/wled/test_sensor.py @@ -10,17 +10,13 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_TIMESTAMP, DOMAIN as SENSOR_DOMAIN, ) -from homeassistant.components.wled.const import ( - ATTR_LED_COUNT, - ATTR_MAX_POWER, - CURRENT_MA, - DOMAIN, -) +from homeassistant.components.wled.const import ATTR_LED_COUNT, ATTR_MAX_POWER, DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, DATA_BYTES, + ELECTRIC_CURRENT_MILLIAMPERE, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, STATE_UNKNOWN, @@ -101,7 +97,9 @@ async def test_sensors( assert state.attributes.get(ATTR_ICON) == "mdi:power" assert state.attributes.get(ATTR_LED_COUNT) == 30 assert state.attributes.get(ATTR_MAX_POWER) == 850 - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENT_MA + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ELECTRIC_CURRENT_MILLIAMPERE + ) assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT assert state.state == "470" diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 3ec17c3e6d3..f8ab8794c0d 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -85,6 +85,16 @@ class TestWorkdaySetup: } } + self.config_remove_named_holidays = { + "binary_sensor": { + "platform": "workday", + "country": "US", + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "excludes": ["sat", "sun", "holiday"], + "remove_holidays": ["Not a Holiday", "Christmas", "Thanksgiving"], + } + } + self.config_tomorrow = { "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1} } @@ -320,3 +330,17 @@ class TestWorkdaySetup: entity = self.hass.states.get("binary_sensor.workday_sensor") assert entity.state == "on" + + # Freeze time to test Fri, but remove holiday by name - Christmas + @patch(FUNCTION_PATH, return_value=date(2020, 12, 25)) + def test_config_remove_named_holidays_xmas(self, mock_date): + """Test if removed by name holidays are reported correctly.""" + with assert_setup_component(1, "binary_sensor"): + setup_component( + self.hass, "binary_sensor", self.config_remove_named_holidays + ) + + self.hass.start() + + entity = self.hass.states.get("binary_sensor.workday_sensor") + assert entity.state == "on" diff --git a/tests/components/wunderground/__init__.py b/tests/components/wunderground/__init__.py deleted file mode 100644 index d3f839a35f6..00000000000 --- a/tests/components/wunderground/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the wunderground component.""" diff --git a/tests/components/wunderground/test_sensor.py b/tests/components/wunderground/test_sensor.py deleted file mode 100644 index 8709f5b6a46..00000000000 --- a/tests/components/wunderground/test_sensor.py +++ /dev/null @@ -1,188 +0,0 @@ -"""The tests for the WUnderground platform.""" -import aiohttp -from pytest import raises - -import homeassistant.components.wunderground.sensor as wunderground -from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, - LENGTH_INCHES, - STATE_UNKNOWN, - TEMP_CELSIUS, -) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture - -VALID_CONFIG_PWS = { - "platform": "wunderground", - "api_key": "foo", - "pws_id": "bar", - "monitored_conditions": [ - "weather", - "feelslike_c", - "alerts", - "elevation", - "location", - ], -} - -VALID_CONFIG = { - "platform": "wunderground", - "api_key": "foo", - "lang": "EN", - "monitored_conditions": [ - "weather", - "feelslike_c", - "alerts", - "elevation", - "location", - "weather_1d_metric", - "precip_1d_in", - ], -} - -INVALID_CONFIG = { - "platform": "wunderground", - "api_key": "BOB", - "pws_id": "bar", - "lang": "foo", - "monitored_conditions": ["weather", "feelslike_c", "alerts"], -} - -URL = ( - "http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang" - ":EN/q/32.87336,-117.22743.json" -) -PWS_URL = "http://api.wunderground.com/api/foo/alerts/conditions/lang:EN/q/pws:bar.json" -INVALID_URL = ( - "http://api.wunderground.com/api/BOB/alerts/conditions/lang:foo/q/pws:bar.json" -) - - -async def test_setup(hass, aioclient_mock): - """Test that the component is loaded.""" - aioclient_mock.get(URL, text=load_fixture("wunderground-valid.json")) - - with assert_setup_component(1, "sensor"): - await async_setup_component(hass, "sensor", {"sensor": VALID_CONFIG}) - await hass.async_block_till_done() - - -async def test_setup_pws(hass, aioclient_mock): - """Test that the component is loaded with PWS id.""" - aioclient_mock.get(PWS_URL, text=load_fixture("wunderground-valid.json")) - - with assert_setup_component(1, "sensor"): - await async_setup_component(hass, "sensor", {"sensor": VALID_CONFIG_PWS}) - - -async def test_setup_invalid(hass, aioclient_mock): - """Test that the component is not loaded with invalid config.""" - aioclient_mock.get(INVALID_URL, text=load_fixture("wunderground-error.json")) - - with assert_setup_component(0, "sensor"): - await async_setup_component(hass, "sensor", {"sensor": INVALID_CONFIG}) - - -async def test_sensor(hass, aioclient_mock): - """Test the WUnderground sensor class and methods.""" - aioclient_mock.get(URL, text=load_fixture("wunderground-valid.json")) - - await async_setup_component(hass, "sensor", {"sensor": VALID_CONFIG}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.pws_weather") - assert state.state == "Clear" - assert state.name == "Weather Summary" - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes - assert ( - state.attributes["entity_picture"] == "https://icons.wxug.com/i/c/k/clear.gif" - ) - - state = hass.states.get("sensor.pws_alerts") - assert state.state == "1" - assert state.name == "Alerts" - assert state.attributes["Message"] == "This is a test alert message" - assert state.attributes["icon"] == "mdi:alert-circle-outline" - assert "entity_picture" not in state.attributes - - state = hass.states.get("sensor.pws_location") - assert state.state == "Holly Springs, NC" - assert state.name == "Location" - - state = hass.states.get("sensor.pws_elevation") - assert state.state == "413" - assert state.name == "Elevation" - - state = hass.states.get("sensor.pws_feelslike_c") - assert state.state == "40" - assert state.name == "Feels Like" - assert "entity_picture" not in state.attributes - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - - state = hass.states.get("sensor.pws_weather_1d_metric") - assert state.state == "Mostly Cloudy. Fog overnight." - assert state.name == "Tuesday" - - state = hass.states.get("sensor.pws_precip_1d_in") - assert state.state == "0.03" - assert state.name == "Precipitation Intensity Today" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == LENGTH_INCHES - - -async def test_connect_failed(hass, aioclient_mock): - """Test the WUnderground connection error.""" - aioclient_mock.get(URL, exc=aiohttp.ClientError()) - with raises(PlatformNotReady): - await wunderground.async_setup_platform(hass, VALID_CONFIG, lambda _: None) - - -async def test_invalid_data(hass, aioclient_mock): - """Test the WUnderground invalid data.""" - aioclient_mock.get(URL, text=load_fixture("wunderground-invalid.json")) - - await async_setup_component(hass, "sensor", {"sensor": VALID_CONFIG}) - await hass.async_block_till_done() - - for condition in VALID_CONFIG["monitored_conditions"]: - state = hass.states.get(f"sensor.pws_{condition}") - assert state.state == STATE_UNKNOWN - - -async def test_entity_id_with_multiple_stations(hass, aioclient_mock): - """Test not generating duplicate entity ids with multiple stations.""" - aioclient_mock.get(URL, text=load_fixture("wunderground-valid.json")) - aioclient_mock.get(PWS_URL, text=load_fixture("wunderground-valid.json")) - - config = [VALID_CONFIG, {**VALID_CONFIG_PWS, "entity_namespace": "hi"}] - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.pws_weather") - assert state is not None - assert state.state == "Clear" - - state = hass.states.get("sensor.hi_pws_weather") - assert state is not None - assert state.state == "Clear" - - -async def test_fails_because_of_unique_id(hass, aioclient_mock): - """Test same config twice fails because of unique_id.""" - aioclient_mock.get(URL, text=load_fixture("wunderground-valid.json")) - aioclient_mock.get(PWS_URL, text=load_fixture("wunderground-valid.json")) - - config = [ - VALID_CONFIG, - {**VALID_CONFIG, "entity_namespace": "hi"}, - VALID_CONFIG_PWS, - ] - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - - states = hass.states.async_all() - expected = len(VALID_CONFIG["monitored_conditions"]) + len( - VALID_CONFIG_PWS["monitored_conditions"] - ) - assert len(states) == expected diff --git a/tests/components/yale_smart_alarm/__init__.py b/tests/components/yale_smart_alarm/__init__.py new file mode 100644 index 00000000000..472ef33a083 --- /dev/null +++ b/tests/components/yale_smart_alarm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Yale Smart Living integration.""" diff --git a/tests/components/yale_smart_alarm/test_config_flow.py b/tests/components/yale_smart_alarm/test_config_flow.py new file mode 100644 index 00000000000..142e1ac5b5d --- /dev/null +++ b/tests/components/yale_smart_alarm/test_config_flow.py @@ -0,0 +1,224 @@ +"""Test the Yale Smart Living config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from yalesmartalarmclient.client import AuthenticationError + +from homeassistant import config_entries, setup +from homeassistant.components.yale_smart_alarm.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + 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"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_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} + ) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=AuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +@pytest.mark.parametrize( + "input,output", + [ + ( + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ), + ( + { + "username": "test-username", + "password": "test-password", + }, + { + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ), + ], +) +async def test_import_flow_success(hass, input: dict[str, str], output: dict[str, str]): + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ), patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=input, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-username" + assert result2["data"] == output + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + assert result["step_id"] == "reauth_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + ) as mock_yale, patch( + "homeassistant.components.yale_smart_alarm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + assert entry.data == { + "username": "test-username", + "password": "new-test-password", + "name": "Yale Smart Alarm", + "area_id": "1", + } + + assert len(mock_yale.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_flow_invalid_login(hass: HomeAssistant) -> None: + """Test a reauthentication flow.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="test-username", + data={ + "username": "test-username", + "password": "test-password", + }, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": entry.unique_id, + "entry_id": entry.entry_id, + }, + data=entry.data, + ) + + with patch( + "homeassistant.components.yale_smart_alarm.config_flow.YaleSmartAlarmClient", + side_effect=AuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "wrong-password", + }, + ) + await hass.async_block_till_done() + + assert result2["step_id"] == "reauth_confirm" + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} diff --git a/tests/components/youless/__init__.py b/tests/components/youless/__init__.py new file mode 100644 index 00000000000..8711c6721bc --- /dev/null +++ b/tests/components/youless/__init__.py @@ -0,0 +1 @@ +"""Tests for the youless component.""" diff --git a/tests/components/youless/test_config_flows.py b/tests/components/youless/test_config_flows.py new file mode 100644 index 00000000000..d7d9a39ec6e --- /dev/null +++ b/tests/components/youless/test_config_flows.py @@ -0,0 +1,72 @@ +"""Test the youless config flow.""" +from unittest.mock import MagicMock, patch +from urllib.error import URLError + +from homeassistant.components.youless import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + + +def _get_mock_youless_api(initialize=None): + mock_youless = MagicMock() + if isinstance(initialize, Exception): + type(mock_youless).initialize = MagicMock(side_effect=initialize) + else: + type(mock_youless).initialize = MagicMock(return_value=initialize) + + type(mock_youless).mac_address = None + return mock_youless + + +async def test_full_flow(hass: HomeAssistant) -> None: + """Check setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {} + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_youless = _get_mock_youless_api( + initialize={"homes": [{"id": 1, "name": "myhome"}]} + ) + with patch( + "homeassistant.components.youless.config_flow.YoulessAPI", + return_value=mock_youless, + ) as mocked_youless: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "localhost"}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "localhost" + assert len(mocked_youless.mock_calls) == 1 + + +async def test_not_found(hass: HomeAssistant) -> None: + """Check setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("errors") == {} + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_youless = _get_mock_youless_api(initialize=URLError("")) + with patch( + "homeassistant.components.youless.config_flow.YoulessAPI", + return_value=mock_youless, + ) as mocked_youless: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "localhost"}, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert len(mocked_youless.mock_calls) == 1 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 68c0785e60b..3b8cf883a13 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,11 +1,16 @@ """Test Zeroconf component setup process.""" +from ipaddress import ip_address from unittest.mock import call, patch from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf -from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 +from homeassistant.components.zeroconf import ( + CONF_DEFAULT_INTERFACE, + CONF_IPV6, + _get_announced_addresses, +) from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, @@ -726,10 +731,16 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ "ipv6": [ { "address": "2001:db8::", - "network_prefix": 8, + "network_prefix": 64, "flowinfo": 1, "scope_id": 1, - } + }, + { + "address": "fe80::1234:5678:9abc:def0", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, ], "name": "eth0", }, @@ -741,6 +752,21 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ "ipv6": [], "name": "eth1", }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "172.16.1.5", "network_prefix": 23}], + "ipv6": [ + { + "address": "fe80::dead:beef:dead:beef", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 3, + } + ], + "name": "eth2", + }, { "auto": False, "default": False, @@ -764,9 +790,36 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, + ), patch( + "socket.if_nametoindex", + side_effect=lambda iface: {"eth0": 1, "eth1": 2, "eth2": 3, "vtun0": 4}.get( + iface, 0 + ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + assert mock_zc.mock_calls[0] == call( + interfaces=[1, "192.168.1.5", "172.16.1.5", 3], ip_version=IPVersion.All + ) - assert mock_zc.mock_calls[0] == call(interfaces=[1, "192.168.1.5"]) + +async def test_get_announced_addresses(hass, mock_async_zeroconf): + """Test addresses for mDNS announcement.""" + expected = { + ip_address(ip).packed + for ip in [ + "fe80::1234:5678:9abc:def0", + "2001:db8::", + "192.168.1.5", + "fe80::dead:beef:dead:beef", + "172.16.1.5", + ] + } + first_ip = ip_address("172.16.1.5").packed + actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) + assert actual[0] == first_ip and set(actual) == expected + + first_ip = ip_address("192.168.1.5").packed + actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) + assert actual[0] == first_ip and set(actual) == expected diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 8694b59ecfb..288f886a865 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -136,7 +136,7 @@ async def test_device_cluster_attributes(zha_client): msg = await zha_client.receive_json() attributes = msg["result"] - assert len(attributes) == 4 + assert len(attributes) == 5 for attribute in attributes: assert attribute[ID] is not None diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index cd0e75a7237..9dc71d4aa25 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -266,7 +266,7 @@ async def test_discover_endpoint(device_info, channels_mock, hass): ) assert device_info["event_channels"] == sorted( - [ch.id for pool in channels.pools for ch in pool.client_channels.values()] + ch.id for pool in channels.pools for ch in pool.client_channels.values() ) assert new_ent.call_count == len( [ diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index fe367a3969b..0408f164049 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -421,7 +421,7 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected await send_attributes_report( hass, cluster, {1: level + 10, 0: level, 2: level - 10 or 22} ) - await hass.async_block_till_done() + await async_wait_for_updates(hass) assert hass.states.get(entity_id).state == expected_state # hass uses None for brightness of 0 in state attributes if level == 0: diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py index b2cd895df37..ae0fa44ed8c 100644 --- a/tests/components/zwave/test_sensor.py +++ b/tests/components/zwave/test_sensor.py @@ -84,6 +84,7 @@ def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert device.state == 191.0 assert device.unit_of_measurement == homeassistant.const.TEMP_FAHRENHEIT + assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE value.data = 197.95555 value_changed(value) assert device.state == 198.0 @@ -103,6 +104,7 @@ def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert device.state == 38.9 assert device.unit_of_measurement == homeassistant.const.TEMP_CELSIUS + assert device.device_class == homeassistant.const.DEVICE_CLASS_TEMPERATURE value.data = 37.95555 value_changed(value) assert device.state == 38.0 @@ -124,6 +126,7 @@ def test_multilevelsensor_value_changed_other_units(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert device.state == 190.96 assert device.unit_of_measurement == homeassistant.const.ENERGY_KILO_WATT_HOUR + assert device.device_class is None value.data = 197.95555 value_changed(value) assert device.state == 197.96 @@ -143,6 +146,7 @@ def test_multilevelsensor_value_changed_integer(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert device.state == 5 assert device.unit_of_measurement == "counts" + assert device.device_class is None value.data = 6 value_changed(value) assert device.state == 6 @@ -159,6 +163,7 @@ def test_alarm_sensor_value_changed(mock_openzwave): device = sensor.get_device(node=node, values=values, node_config={}) assert device.state == 12.34 assert device.unit_of_measurement == homeassistant.const.PERCENTAGE + assert device.device_class is None value.data = 45.67 value_changed(value) assert device.state == 45.67 diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index de80eb6bbc5..44943fed9fb 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -1,8 +1,12 @@ """Provide common test tools for Z-Wave JS.""" +from datetime import datetime, timezone + AIR_TEMPERATURE_SENSOR = "sensor.multisensor_6_air_temperature" HUMIDITY_SENSOR = "sensor.multisensor_6_humidity" -ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" POWER_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed" +ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" +VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3" +CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4" SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" @@ -11,6 +15,8 @@ NOTIFICATION_MOTION_BINARY_SENSOR = ( "binary_sensor.multisensor_6_home_security_motion_detection" ) NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" +INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" +BASIC_SENSOR = "sensor.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) @@ -22,7 +28,13 @@ CLIMATE_MAIN_HEAT_ACTIONNER = "climate.main_heat_actionner" BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" EATON_RF9640_ENTITY = "light.allloaddimmer" AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" +SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt" ID_LOCK_CONFIG_PARAMETER_SENSOR = ( "sensor.z_wave_module_for_id_lock_150_and_101_config_parameter_door_lock_mode" ) ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" +METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" +METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" + +DATETIME_ZERO = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc) +DATETIME_LAST_RESET = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index f0c69709031..0f336e396fe 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,6 +11,11 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo +from homeassistant.components.sensor import ATTR_LAST_RESET +from homeassistant.core import State + +from .common import DATETIME_LAST_RESET + from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -350,6 +355,12 @@ def qubino_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_qubino_shutter_state.json")) +@pytest.fixture(name="aeotec_nano_shutter_state", scope="session") +def aeotec_nano_shutter_state_fixture(): + """Load the Aeotec Nano Shutter node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_aeotec_nano_shutter_state.json")) + + @pytest.fixture(name="aeon_smart_switch_6_state", scope="session") def aeon_smart_switch_6_state_fixture(): """Load the AEON Labs (ZW096) Smart Switch 6 node state fixture data.""" @@ -429,6 +440,18 @@ def wallmote_central_scene_state_fixture(): return json.loads(load_fixture("zwave_js/wallmote_central_scene_state.json")) +@pytest.fixture(name="ge_in_wall_dimmer_switch_state", scope="session") +def ge_in_wall_dimmer_switch_state_fixture(): + """Load the ge in-wall dimmer switch node state fixture data.""" + return json.loads(load_fixture("zwave_js/ge_in_wall_dimmer_switch_state.json")) + + +@pytest.fixture(name="aeotec_zw164_siren_state", scope="session") +def aeotec_zw164_siren_state_fixture(): + """Load the aeotec zw164 siren node state fixture data.""" + return json.loads(load_fixture("zwave_js/aeotec_zw164_siren_state.json")) + + @pytest.fixture(name="client") def mock_client_fixture(controller_state, version_state, log_config_state): """Mock a client.""" @@ -703,6 +726,14 @@ def qubino_shutter_cover_fixture(client, qubino_shutter_state): return node +@pytest.fixture(name="aeotec_nano_shutter") +def aeotec_nano_shutter_cover_fixture(client, aeotec_nano_shutter_state): + """Mock a Aeotec Nano Shutter node.""" + node = Node(client, copy.deepcopy(aeotec_nano_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="aeon_smart_switch_6") def aeon_smart_switch_6_fixture(client, aeon_smart_switch_6_state): """Mock an AEON Labs (ZW096) Smart Switch 6 node.""" @@ -789,7 +820,36 @@ def wallmote_central_scene_fixture(client, wallmote_central_scene_state): return node +@pytest.fixture(name="ge_in_wall_dimmer_switch") +def ge_in_wall_dimmer_switch_fixture(client, ge_in_wall_dimmer_switch_state): + """Mock a ge in-wall dimmer switch scene node.""" + node = Node(client, copy.deepcopy(ge_in_wall_dimmer_switch_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="aeotec_zw164_siren") +def aeotec_zw164_siren_fixture(client, aeotec_zw164_siren_state): + """Mock a wallmote central scene node.""" + node = Node(client, copy.deepcopy(aeotec_zw164_siren_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="firmware_file") def firmware_file_fixture(): """Return mock firmware file stream.""" return io.BytesIO(bytes(10)) + + +@pytest.fixture(name="restore_last_reset") +def restore_last_reset_fixture(): + """Return mock restore last reset.""" + state = State( + "sensor.test", "test", {ATTR_LAST_RESET: DATETIME_LAST_RESET.isoformat()} + ) + with patch( + "homeassistant.components.zwave_js.sensor.ZWaveMeterSensor.async_get_last_state", + return_value=state, + ): + yield state diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index b6d846898d3..75fca7f11ff 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -7,6 +7,7 @@ from zwave_js_server.const import LogLevel from zwave_js_server.event import Event from zwave_js_server.exceptions import ( FailedCommand, + FailedZWaveCommand, InvalidNewValue, NotFoundError, SetValueFailed, @@ -280,13 +281,32 @@ async def test_ping_node( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_ping", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/ping_node", + ENTRY_ID: entry.entry_id, + NODE_ID: node.node_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 5, + ID: 6, TYPE: "zwave_js/ping_node", ENTRY_ID: entry.entry_id, NODE_ID: node.node_id, @@ -385,12 +405,30 @@ async def test_add_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "interview failed" + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_begin_inclusion", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( - {ID: 4, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + {ID: 5, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -419,12 +457,48 @@ async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_cli msg = await ws_client.receive_json() assert msg["success"] + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_stop_inclusion", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 6, + TYPE: "zwave_js/stop_inclusion", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_stop_exclusion", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/stop_exclusion", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( - {ID: 6, TYPE: "zwave_js/stop_inclusion", ENTRY_ID: entry.entry_id} + {ID: 8, TYPE: "zwave_js/stop_inclusion", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -432,7 +506,7 @@ async def test_cancel_inclusion_exclusion(hass, integration, client, hass_ws_cli assert msg["error"]["code"] == ERR_NOT_LOADED await ws_client.send_json( - {ID: 7, TYPE: "zwave_js/stop_exclusion", ENTRY_ID: entry.entry_id} + {ID: 9, TYPE: "zwave_js/stop_exclusion", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -494,12 +568,30 @@ async def test_remove_node( ) assert device is None + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_begin_exclusion", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/remove_node", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( - {ID: 4, TYPE: "zwave_js/remove_node", ENTRY_ID: entry.entry_id} + {ID: 5, TYPE: "zwave_js/remove_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -641,13 +733,32 @@ async def test_replace_failed_node( msg = await ws_client.receive_json() assert msg["event"]["event"] == "interview failed" + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_replace_failed_node", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/replace_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/replace_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -705,13 +816,32 @@ async def test_remove_failed_node( ) assert device is None + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_remove_failed_node", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/remove_failed_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/remove_failed_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -747,13 +877,31 @@ async def test_begin_healing_network( assert msg["success"] assert msg["result"] + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_begin_healing_network", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/begin_healing_network", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/begin_healing_network", ENTRY_ID: entry.entry_id, } @@ -781,6 +929,7 @@ async def test_subscribe_heal_network_progress( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"] is None # Fire heal network progress event = Event( @@ -813,6 +962,39 @@ async def test_subscribe_heal_network_progress( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_subscribe_heal_network_progress_initial_value( + hass, integration, client, hass_ws_client +): + """Test subscribe_heal_network_progress command when heal network in progress.""" + entry = integration + ws_client = await hass_ws_client(hass) + + assert not client.driver.controller.heal_network_progress + + # Fire heal network progress before sending heal network progress command + event = Event( + "heal network progress", + { + "source": "controller", + "event": "heal network progress", + "progress": {67: "pending"}, + }, + ) + client.driver.controller.receive_event(event) + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/subscribe_heal_network_progress", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == {"67": "pending"} + + async def test_stop_healing_network( hass, integration, @@ -837,13 +1019,31 @@ async def test_stop_healing_network( assert msg["success"] assert msg["result"] + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_stop_healing_network", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/stop_healing_network", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/stop_healing_network", ENTRY_ID: entry.entry_id, } @@ -879,13 +1079,32 @@ async def test_heal_node( assert msg["success"] assert msg["result"] + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_heal_node", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/heal_node", + ENTRY_ID: entry.entry_id, + NODE_ID: 67, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/heal_node", ENTRY_ID: entry.entry_id, NODE_ID: 67, @@ -978,13 +1197,32 @@ async def test_refresh_node_info( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_refresh_info", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/refresh_node_info", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 3, + ID: 4, TYPE: "zwave_js/refresh_node_info", ENTRY_ID: entry.entry_id, NODE_ID: 52, @@ -1048,6 +1286,42 @@ async def test_refresh_node_values( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_refresh_values", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/refresh_node_values", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/refresh_node_values", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + async def test_refresh_node_cc_values( hass, client, multisensor_6, integration, hass_ws_client @@ -1105,13 +1379,33 @@ async def test_refresh_node_cc_values( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_refresh_cc_values", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/refresh_node_cc_values", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + COMMAND_CLASS_ID: 112, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/refresh_node_cc_values", ENTRY_ID: entry.entry_id, NODE_ID: 52, @@ -1307,13 +1601,35 @@ async def test_set_config_parameter( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + # Test FailedZWaveCommand is caught + with patch( + "homeassistant.components.zwave_js.api.async_set_config_parameter", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/set_config_parameter", + ENTRY_ID: entry.entry_id, + NODE_ID: 52, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 7, + ID: 8, TYPE: "zwave_js/set_config_parameter", ENTRY_ID: entry.entry_id, NODE_ID: 52, @@ -1625,12 +1941,30 @@ async def test_subscribe_log_updates(hass, integration, client, hass_ws_client): }, } + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_start_listening_logs", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/subscribe_log_updates", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( - {ID: 2, TYPE: "zwave_js/subscribe_log_updates", ENTRY_ID: entry.entry_id} + {ID: 3, TYPE: "zwave_js/subscribe_log_updates", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() @@ -1757,13 +2091,32 @@ async def test_update_log_config(hass, client, integration, hass_ws_client): and "must be provided if logging to file" in msg["error"]["message"] ) + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_update_log_config", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 7, + TYPE: "zwave_js/update_log_config", + ENTRY_ID: entry.entry_id, + CONFIG: {LEVEL: "Error"}, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 7, + ID: 8, TYPE: "zwave_js/update_log_config", ENTRY_ID: entry.entry_id, CONFIG: {LEVEL: "Error"}, @@ -1884,13 +2237,50 @@ async def test_data_collection(hass, client, integration, hass_ws_client): client.async_send_command.reset_mock() + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_is_statistics_enabled", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/data_collection_status", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_enable_statistics", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/update_data_collection_preference", + ENTRY_ID: entry.entry_id, + OPTED_IN: True, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 4, + ID: 6, TYPE: "zwave_js/data_collection_status", ENTRY_ID: entry.entry_id, } @@ -1902,7 +2292,7 @@ async def test_data_collection(hass, client, integration, hass_ws_client): await ws_client.send_json( { - ID: 5, + ID: 7, TYPE: "zwave_js/update_data_collection_preference", ENTRY_ID: entry.entry_id, OPTED_IN: True, @@ -1938,6 +2328,42 @@ async def test_abort_firmware_update( assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_abort_firmware_update", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/abort_firmware_update", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/abort_firmware_update", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + async def test_abort_firmware_update_failures( hass, integration, multisensor_6, client, hass_ws_client @@ -2011,6 +2437,7 @@ async def test_subscribe_firmware_update_status( msg = await ws_client.receive_json() assert msg["success"] + assert msg["result"] is None event = Event( type="firmware update progress", @@ -2051,6 +2478,44 @@ async def test_subscribe_firmware_update_status( } +async def test_subscribe_firmware_update_status_initial_value( + hass, integration, multisensor_6, client, hass_ws_client +): + """Test subscribe_firmware_update_status websocket command with in progress update.""" + entry = integration + ws_client = await hass_ws_client(hass) + + assert multisensor_6.firmware_update_progress is None + + # Send a firmware update progress event before the WS command + event = Event( + type="firmware update progress", + data={ + "source": "node", + "event": "firmware update progress", + "nodeId": multisensor_6.node_id, + "sentFragments": 1, + "totalFragments": 10, + }, + ) + multisensor_6.receive_event(event) + + client.async_send_command_no_wait.return_value = {} + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/subscribe_firmware_update_status", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == {"sent_fragments": 1, "total_fragments": 10} + + async def test_subscribe_firmware_update_status_failures( hass, integration, multisensor_6, client, hass_ws_client ): @@ -2128,13 +2593,31 @@ async def test_check_for_config_updates(hass, client, integration, hass_ws_clien assert config_update["update_available"] assert config_update["new_version"] == "test" + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_check_for_config_updates", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/check_for_config_updates", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 2, + ID: 3, TYPE: "zwave_js/check_for_config_updates", ENTRY_ID: entry.entry_id, } @@ -2146,7 +2629,7 @@ async def test_check_for_config_updates(hass, client, integration, hass_ws_clien await ws_client.send_json( { - ID: 3, + ID: 4, TYPE: "zwave_js/check_for_config_updates", ENTRY_ID: "INVALID", } @@ -2175,13 +2658,31 @@ async def test_install_config_update(hass, client, integration, hass_ws_client): assert msg["result"] assert msg["success"] + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_install_config_update", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/install_config_update", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + # Test sending command with not loaded entry fails await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() await ws_client.send_json( { - ID: 2, + ID: 3, TYPE: "zwave_js/install_config_update", ENTRY_ID: entry.entry_id, } @@ -2193,7 +2694,7 @@ async def test_install_config_update(hass, client, integration, hass_ws_client): await ws_client.send_json( { - ID: 3, + ID: 4, TYPE: "zwave_js/install_config_update", ENTRY_ID: "INVALID", } @@ -2202,3 +2703,195 @@ async def test_install_config_update(hass, client, integration, hass_ws_client): assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND + + +async def test_subscribe_controller_statistics( + hass, integration, client, hass_ws_client +): + """Test the subscribe_controller_statistics command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/subscribe_controller_statistics", + ENTRY_ID: entry.entry_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "messages_tx": 0, + "messages_rx": 0, + "messages_dropped_tx": 0, + "messages_dropped_rx": 0, + "nak": 0, + "can": 0, + "timeout_ack": 0, + "timout_response": 0, + "timeout_callback": 0, + } + + # Fire statistics updated + event = Event( + "statistics updated", + { + "source": "controller", + "event": "statistics updated", + "statistics": { + "messagesTX": 1, + "messagesRX": 1, + "messagesDroppedTX": 1, + "messagesDroppedRX": 1, + "NAK": 1, + "CAN": 1, + "timeoutACK": 1, + "timeoutResponse": 1, + "timeoutCallback": 1, + }, + }, + ) + client.driver.controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "statistics updated", + "source": "controller", + "messages_tx": 1, + "messages_rx": 1, + "messages_dropped_tx": 1, + "messages_dropped_rx": 1, + "nak": 1, + "can": 1, + "timeout_ack": 1, + "timout_response": 1, + "timeout_callback": 1, + } + + # Test sending command with improper entry ID fails + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/subscribe_controller_statistics", + ENTRY_ID: "fake_entry_id", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/subscribe_controller_statistics", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_subscribe_node_statistics( + hass, multisensor_6, integration, client, hass_ws_client +): + """Test the subscribe_node_statistics command.""" + entry = integration + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/subscribe_node_statistics", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "commands_tx": 0, + "commands_rx": 0, + "commands_dropped_tx": 0, + "commands_dropped_rx": 0, + "timeout_response": 0, + } + + # Fire statistics updated + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": multisensor_6.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 1, + "commandsDroppedTX": 1, + "commandsDroppedRX": 1, + "timeoutResponse": 1, + }, + }, + ) + client.driver.controller.receive_event(event) + msg = await ws_client.receive_json() + assert msg["event"] == { + "event": "statistics updated", + "source": "node", + "node_id": multisensor_6.node_id, + "commands_tx": 1, + "commands_rx": 1, + "commands_dropped_tx": 1, + "commands_dropped_rx": 1, + "timeout_response": 1, + } + + # Test sending command with improper entry ID fails + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/subscribe_node_statistics", + ENTRY_ID: "fake_entry_id", + NODE_ID: multisensor_6.node_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + # Test sending command with improper node ID fails + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/subscribe_node_statistics", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id + 100, + } + ) + msg = await ws_client.receive_json() + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/subscribe_node_statistics", + ENTRY_ID: entry.entry_id, + NODE_ID: multisensor_6.node_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index 9d7a16ac8cf..70ce2337abf 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -24,6 +24,7 @@ WINDOW_COVER_ENTITY = "cover.zws_12" GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter_dc" +AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" async def test_window_cover(hass, client, chain_actuator_zws12, integration): @@ -306,6 +307,215 @@ async def test_window_cover(hass, client, chain_actuator_zws12, integration): assert state.state == "closed" +async def test_aeotec_nano_shutter_cover( + hass, client, aeotec_nano_shutter, integration +): + """Test movement of an Aeotec Nano Shutter cover entity. Useful to make sure the stop command logic is handled properly.""" + node = aeotec_nano_shutter + state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) + + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_WINDOW + + assert state.state == "closed" + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + + # Test opening + await hass.services.async_call( + "cover", + "open_cover", + {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 3 + assert args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "value": 0, + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "valueChangeOptions": ["transitionDuration"], + "readable": True, + "writeable": True, + "label": "Target value", + }, + } + assert args["value"] + + client.async_send_command.reset_mock() + # Test stop after opening + await hass.services.async_call( + "cover", + "stop_cover", + {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + open_args = client.async_send_command.call_args_list[0][0][0] + assert open_args["command"] == "node.set_value" + assert open_args["nodeId"] == 3 + assert open_args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "On", + "propertyName": "On", + "value": False, + "metadata": { + "type": "boolean", + "readable": True, + "writeable": True, + "label": "Perform a level change (On)", + "ccSpecific": {"switchType": 1}, + }, + } + assert not open_args["value"] + + close_args = client.async_send_command.call_args_list[1][0][0] + assert close_args["command"] == "node.set_value" + assert close_args["nodeId"] == 3 + assert close_args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "Off", + "propertyName": "Off", + "value": False, + "metadata": { + "type": "boolean", + "readable": True, + "writeable": True, + "label": "Perform a level change (Off)", + "ccSpecific": {"switchType": 1}, + }, + } + assert not close_args["value"] + + # Test position update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 6, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + client.async_send_command.reset_mock() + + state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) + assert state.state == "open" + + # Test closing + await hass.services.async_call( + "cover", + "close_cover", + {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 3 + assert args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "value": 0, + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "valueChangeOptions": ["transitionDuration"], + "label": "Target value", + }, + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + # Test stop after closing + await hass.services.async_call( + "cover", + "stop_cover", + {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + open_args = client.async_send_command.call_args_list[0][0][0] + assert open_args["command"] == "node.set_value" + assert open_args["nodeId"] == 3 + assert open_args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "On", + "propertyName": "On", + "value": False, + "metadata": { + "type": "boolean", + "readable": True, + "writeable": True, + "label": "Perform a level change (On)", + "ccSpecific": {"switchType": 1}, + }, + } + assert not open_args["value"] + + close_args = client.async_send_command.call_args_list[1][0][0] + assert close_args["command"] == "node.set_value" + assert close_args["nodeId"] == 3 + assert close_args["valueId"] == { + "ccVersion": 4, + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "Off", + "propertyName": "Off", + "value": False, + "metadata": { + "type": "boolean", + "readable": True, + "writeable": True, + "label": "Perform a level change (Off)", + "ccSpecific": {"switchType": 1}, + }, + } + assert not close_args["value"] + + async def test_blind_cover(hass, client, iblinds_v2, integration): """Test a blind cover entity.""" state = hass.states.get(BLIND_COVER_ENTITY) diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py new file mode 100644 index 00000000000..eef672c4c5b --- /dev/null +++ b/tests/components/zwave_js/test_device_condition.py @@ -0,0 +1,572 @@ +"""The tests for Z-Wave JS device conditions.""" +from __future__ import annotations + +from unittest.mock import patch + +import pytest +import voluptuous as vol +import voluptuous_serialize +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN, device_condition +from homeassistant.components.zwave_js.helpers import get_zwave_value_from_config +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, device_registry +from homeassistant.setup import async_setup_component + +from tests.common import async_get_device_automations, async_mock_service + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_conditions(hass, client, lock_schlage_be469, integration) -> None: + """Test we get the expected onditions from a zwave_js.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + config_value = list(lock_schlage_be469.get_configuration_values().values())[0] + value_id = config_value.value_id + name = config_value.property_name + + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "node_status", + "device_id": device.id, + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "config_parameter", + "device_id": device.id, + "value_id": value_id, + "subtype": f"{value_id} ({name})", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "value", + "device_id": device.id, + }, + ] + conditions = await async_get_device_automations(hass, "condition", device.id) + for condition in expected_conditions: + assert condition in conditions + + +async def test_node_status_state( + hass, client, lock_schlage_be469, integration, calls +) -> None: + """Test for node_status conditions.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "node_status", + "status": "alive", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "alive - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "node_status", + "status": "awake", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "awake - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "node_status", + "status": "asleep", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "asleep - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event4"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "node_status", + "status": "dead", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "dead - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "alive - event - test_event1" + + event = Event( + "wake up", + data={ + "source": "node", + "event": "wake up", + "nodeId": lock_schlage_be469.node_id, + }, + ) + lock_schlage_be469.receive_event(event) + await hass.async_block_till_done() + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "awake - event - test_event2" + + event = Event( + "sleep", + data={"source": "node", "event": "sleep", "nodeId": lock_schlage_be469.node_id}, + ) + lock_schlage_be469.receive_event(event) + await hass.async_block_till_done() + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 3 + assert calls[2].data["some"] == "asleep - event - test_event3" + + event = Event( + "dead", + data={"source": "node", "event": "dead", "nodeId": lock_schlage_be469.node_id}, + ) + lock_schlage_be469.receive_event(event) + await hass.async_block_till_done() + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_event4") + await hass.async_block_till_done() + assert len(calls) == 4 + assert calls[3].data["some"] == "dead - event - test_event4" + + event = Event( + "unknown", + data={ + "source": "node", + "event": "unknown", + "nodeId": lock_schlage_be469.node_id, + }, + ) + lock_schlage_be469.receive_event(event) + await hass.async_block_till_done() + + +async def test_config_parameter_state( + hass, client, lock_schlage_be469, integration, calls +) -> None: + """Test for config_parameter conditions.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "config_parameter", + "value_id": f"{lock_schlage_be469.node_id}-112-0-3", + "subtype": f"{lock_schlage_be469.node_id}-112-0-3 (Beeper)", + "value": 255, + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "Beeper - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "config_parameter", + "value_id": f"{lock_schlage_be469.node_id}-112-0-6", + "subtype": f"{lock_schlage_be469.node_id}-112-0-6 (User Slot Status)", + "value": 1, + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "User Slot Status - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + + 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"] == "Beeper - event - test_event1" + + # Flip Beeper state to not match condition + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": lock_schlage_be469.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 3, + "newValue": 0, + "prevValue": 255, + }, + }, + ) + lock_schlage_be469.receive_event(event) + + # Flip User Slot Status to match condition + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": lock_schlage_be469.node_id, + "args": { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 6, + "newValue": 1, + "prevValue": 117440512, + }, + }, + ) + lock_schlage_be469.receive_event(event) + + 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"] == "User Slot Status - event - test_event2" + + +async def test_value_state( + hass, client, lock_schlage_be469, integration, calls +) -> None: + """Test for value conditions.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "value", + "command_class": 112, + "property": 3, + "value": 255, + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "value - {{ trigger.platform }} - {{ trigger.event.event_type }}" + }, + }, + }, + ] + }, + ) + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "value - event - test_event1" + + +async def test_get_condition_capabilities_node_status( + hass, client, lock_schlage_be469, integration +): + """Test we don't get capabilities from a node_status condition.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "node_status", + }, + ) + assert capabilities and "extra_fields" in capabilities + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "status", + "required": True, + "type": "select", + "options": [ + ("asleep", "asleep"), + ("awake", "awake"), + ("dead", "dead"), + ("alive", "alive"), + ], + } + ] + + +async def test_get_condition_capabilities_value( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a value condition.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "value", + }, + ) + assert capabilities and "extra_fields" in capabilities + + cc_options = [(cc.value, cc.name) for cc in CommandClass] + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "command_class", + "required": True, + "options": cc_options, + "type": "select", + }, + {"name": "property", "required": True, "type": "string"}, + {"name": "property_key", "optional": True, "type": "string"}, + {"name": "endpoint", "optional": True, "type": "string"}, + {"name": "value", "required": True, "type": "string"}, + ] + + +async def test_get_condition_capabilities_config_parameter( + hass, client, climate_radio_thermostat_ct100_plus, integration +): + """Test we get the expected capabilities from a config_parameter condition.""" + node = climate_radio_thermostat_ct100_plus + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + # Test enumerated type param + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "config_parameter", + "value_id": f"{node.node_id}-112-0-1", + "subtype": f"{node.node_id}-112-0-1 (Temperature Reporting Threshold)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "options": [ + (0, "Disabled"), + (1, "0.5° F"), + (2, "1.0° F"), + (3, "1.5° F"), + (4, "2.0° F"), + ], + "type": "select", + } + ] + + # Test range type param + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "config_parameter", + "value_id": f"{node.node_id}-112-0-10", + "subtype": f"{node.node_id}-112-0-10 (Temperature Reporting Filter)", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "required": True, + "valueMin": 0, + "valueMax": 124, + } + ] + + # Test undefined type param + capabilities = await device_condition.async_get_condition_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "config_parameter", + "value_id": f"{node.node_id}-112-0-2", + "subtype": f"{node.node_id}-112-0-2 (HVAC Settings)", + }, + ) + assert not capabilities + + +async def test_failure_scenarios(hass, client, hank_binary_switch, integration): + """Test failure scenarios.""" + dev_reg = device_registry.async_get(hass) + device = device_registry.async_entries_for_config_entry( + dev_reg, integration.entry_id + )[0] + + with pytest.raises(HomeAssistantError): + await device_condition.async_condition_from_config( + {"type": "failed.test", "device_id": device.id}, False + ) + + with patch( + "homeassistant.components.zwave_js.device_condition.async_get_node_from_device_id", + return_value=None, + ), patch( + "homeassistant.components.zwave_js.device_condition.get_zwave_value_from_config", + return_value=None, + ): + assert ( + await device_condition.async_get_condition_capabilities( + hass, {"type": "failed.test", "device_id": device.id} + ) + == {} + ) + + +async def test_get_value_from_config_failure( + hass, client, hank_binary_switch, integration +): + """Test get_value_from_config invalid value ID.""" + with pytest.raises(vol.Invalid): + get_zwave_value_from_config( + hank_binary_switch, + { + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": 15, + "endpoint": 10, + }, + ) diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py new file mode 100644 index 00000000000..86e053a5882 --- /dev/null +++ b/tests/components/zwave_js/test_device_trigger.py @@ -0,0 +1,1000 @@ +"""The tests for Z-Wave JS device triggers.""" +from unittest.mock import patch + +import pytest +import voluptuous_serialize +from zwave_js_server.const import CommandClass +from zwave_js_server.event import Event +from zwave_js_server.model.node import Node + +from homeassistant.components import automation +from homeassistant.components.zwave_js import DOMAIN, device_trigger +from homeassistant.components.zwave_js.device_trigger import ( + async_attach_trigger, + async_get_trigger_capabilities, +) +from homeassistant.components.zwave_js.helpers import ( + async_get_node_status_sensor_entity_id, +) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + async_get as async_get_dev_reg, +) +from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg +from homeassistant.setup import async_setup_component + +from tests.common import ( + assert_lists_same, + async_get_device_automations, + async_mock_service, +) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_notification_notification_triggers( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected triggers from a zwave_js device with the Notification CC.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "event.notification.notification", + "device_id": device.id, + "command_class": CommandClass.NOTIFICATION, + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_notification_notification_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for event.notification.notification trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # event, type, label + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.notification", + "command_class": CommandClass.NOTIFICATION.value, + "type.": 6, + "event": 5, + "label": "Access Control", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + # no type, event, label + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.notification", + "command_class": CommandClass.NOTIFICATION.value, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake Notification CC notification + event = Event( + type="notification", + data={ + "source": "node", + "event": "notification", + "nodeId": node.node_id, + "ccId": 113, + "args": { + "type": 6, + "event": 5, + "label": "Access Control", + "eventLabel": "Keypad lock operation", + "parameters": {"userId": 1}, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data[ + "some" + ] == "event.notification.notification - device - zwave_js_notification - {}".format( + CommandClass.NOTIFICATION + ) + assert calls[1].data[ + "some" + ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( + CommandClass.NOTIFICATION + ) + + +async def test_get_trigger_capabilities_notification_notification( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a notification.notification trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.notification", + "command_class": CommandClass.NOTIFICATION.value, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert_lists_same( + voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ), + [ + {"name": "type.", "optional": True, "type": "string"}, + {"name": "label", "optional": True, "type": "string"}, + {"name": "event", "optional": True, "type": "string"}, + {"name": "event_label", "optional": True, "type": "string"}, + ], + ) + + +async def test_if_entry_control_notification_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for notification.entry_control trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # event_type and data_type + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.entry_control", + "command_class": CommandClass.ENTRY_CONTROL.value, + "event_type": 5, + "data_type": 2, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + # no event_type and data_type + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.entry_control", + "command_class": CommandClass.ENTRY_CONTROL.value, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.notification.notification2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake Entry Control CC notification + event = Event( + type="notification", + data={ + "source": "node", + "event": "notification", + "nodeId": node.node_id, + "ccId": 111, + "args": {"eventType": 5, "dataType": 2, "eventData": "555"}, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data[ + "some" + ] == "event.notification.notification - device - zwave_js_notification - {}".format( + CommandClass.ENTRY_CONTROL + ) + assert calls[1].data[ + "some" + ] == "event.notification.notification2 - device - zwave_js_notification - {}".format( + CommandClass.ENTRY_CONTROL + ) + + +async def test_get_trigger_capabilities_entry_control_notification( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a notification.entry_control trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.notification.entry_control", + "command_class": CommandClass.ENTRY_CONTROL.value, + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert_lists_same( + voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ), + [ + {"name": "event_type", "optional": True, "type": "string"}, + {"name": "data_type", "optional": True, "type": "string"}, + ], + ) + + +async def test_get_node_status_triggers(hass, client, lock_schlage_be469, integration): + """Test we get the expected triggers from a device with node status sensor enabled.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + ent_reg = async_get_ent_reg(hass) + entity_id = async_get_node_status_sensor_entity_id( + hass, device.id, ent_reg, dev_reg + ) + ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "state.node_status", + "device_id": device.id, + "entity_id": entity_id, + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_node_status_change_fires( + hass, client, lock_schlage_be469, integration, calls +): + """Test for node_status trigger firing.""" + node: Node = lock_schlage_be469 + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + ent_reg = async_get_ent_reg(hass) + entity_id = async_get_node_status_sensor_entity_id( + hass, device.id, ent_reg, dev_reg + ) + ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # from + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity_id, + "type": "state.node_status", + "from": "alive", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "state.node_status - " + "{{ trigger.platform}} - " + "{{ trigger.from_state.state }}" + ) + }, + }, + }, + # no from or to + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity_id, + "type": "state.node_status", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "state.node_status2 - " + "{{ trigger.platform}} - " + "{{ trigger.from_state.state }}" + ) + }, + }, + }, + ] + }, + ) + + # Test status change + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data["some"] == "state.node_status - device - alive" + assert calls[1].data["some"] == "state.node_status2 - device - alive" + + +async def test_get_trigger_capabilities_node_status( + hass, client, lock_schlage_be469, integration +): + """Test we get the expected capabilities from a node_status trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + ent_reg = async_get_ent_reg(hass) + entity_id = async_get_node_status_sensor_entity_id( + hass, device.id, ent_reg, dev_reg + ) + ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "entity_id": entity_id, + "type": "state.node_status", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "from", + "optional": True, + "options": [ + ("asleep", "asleep"), + ("awake", "awake"), + ("dead", "dead"), + ("alive", "alive"), + ], + "type": "select", + }, + { + "name": "to", + "optional": True, + "options": [ + ("asleep", "asleep"), + ("awake", "awake"), + ("dead", "dead"), + ("alive", "alive"), + ], + "type": "select", + }, + {"name": "for", "optional": True, "type": "positive_time_period_dict"}, + ] + + +async def test_get_basic_value_notification_triggers( + hass, client, ge_in_wall_dimmer_switch, integration +): + """Test we get the expected triggers from a zwave_js device with the Basic CC.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.basic", + "device_id": device.id, + "command_class": CommandClass.BASIC, + "property": "event", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_basic_value_notification_fires( + hass, client, ge_in_wall_dimmer_switch, integration, calls +): + """Test for event.value_notification.basic trigger firing.""" + node: Node = ge_in_wall_dimmer_switch + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.basic", + "device_id": device.id, + "command_class": CommandClass.BASIC.value, + "property": "event", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + "value": 0, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.basic - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + # no value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.basic", + "device_id": device.id, + "command_class": CommandClass.BASIC.value, + "property": "event", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.basic2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake Basic CC value notification + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "event", + "propertyName": "event", + "value": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Event value", + "min": 0, + "max": 255, + }, + "ccVersion": 1, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data[ + "some" + ] == "event.value_notification.basic - device - zwave_js_value_notification - {}".format( + CommandClass.BASIC + ) + assert calls[1].data[ + "some" + ] == "event.value_notification.basic2 - device - zwave_js_value_notification - {}".format( + CommandClass.BASIC + ) + + +async def test_get_trigger_capabilities_basic_value_notification( + hass, client, ge_in_wall_dimmer_switch, integration +): + """Test we get the expected capabilities from a value_notification.basic trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.basic", + "device_id": device.id, + "command_class": CommandClass.BASIC.value, + "property": "event", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "optional": True, + "type": "integer", + "valueMin": 0, + "valueMax": 255, + } + ] + + +async def test_get_central_scene_value_notification_triggers( + hass, client, wallmote_central_scene, integration +): + """Test we get the expected triggers from a zwave_js device with the Central Scene CC.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.central_scene", + "device_id": device.id, + "command_class": CommandClass.CENTRAL_SCENE, + "property": "scene", + "property_key": "001", + "endpoint": 0, + "subtype": "Endpoint 0 Scene 001", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_central_scene_value_notification_fires( + hass, client, wallmote_central_scene, integration, calls +): + """Test for event.value_notification.central_scene trigger firing.""" + node: Node = wallmote_central_scene + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.central_scene", + "command_class": CommandClass.CENTRAL_SCENE.value, + "property": "scene", + "property_key": "001", + "endpoint": 0, + "subtype": "Endpoint 0 Scene 001", + "value": 0, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.central_scene - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + # no value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.central_scene", + "command_class": CommandClass.CENTRAL_SCENE.value, + "property": "scene", + "property_key": "001", + "endpoint": 0, + "subtype": "Endpoint 0 Scene 001", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.central_scene2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake Central Scene CC value notification + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Central Scene", + "commandClass": 91, + "endpoint": 0, + "property": "scene", + "propertyName": "scene", + "propertyKey": "001", + "propertyKey": "001", + "value": 0, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "min": 0, + "max": 255, + "label": "Scene 004", + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + }, + }, + "ccVersion": 1, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data[ + "some" + ] == "event.value_notification.central_scene - device - zwave_js_value_notification - {}".format( + CommandClass.CENTRAL_SCENE + ) + assert calls[1].data[ + "some" + ] == "event.value_notification.central_scene2 - device - zwave_js_value_notification - {}".format( + CommandClass.CENTRAL_SCENE + ) + + +async def test_get_trigger_capabilities_central_scene_value_notification( + hass, client, wallmote_central_scene, integration +): + """Test we get the expected capabilities from a value_notification.central_scene trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.central_scene", + "device_id": device.id, + "command_class": CommandClass.CENTRAL_SCENE.value, + "property": "scene", + "property_key": "001", + "endpoint": 0, + "subtype": "Endpoint 0 Scene 001", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "optional": True, + "type": "select", + "options": [(0, "KeyPressed"), (1, "KeyReleased"), (2, "KeyHeldDown")], + }, + ] + + +async def test_get_scene_activation_value_notification_triggers( + hass, client, hank_binary_switch, integration +): + """Test we get the expected triggers from a zwave_js device with the SceneActivation CC.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.scene_activation", + "device_id": device.id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + } + triggers = await async_get_device_automations(hass, "trigger", device.id) + assert expected_trigger in triggers + + +async def test_if_scene_activation_value_notification_fires( + hass, client, hank_binary_switch, integration, calls +): + """Test for event.value_notification.scene_activation trigger firing.""" + node: Node = hank_binary_switch + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.scene_activation", + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + "value": 1, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.scene_activation - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + # No value + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device.id, + "type": "event.value_notification.scene_activation", + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": ( + "event.value_notification.scene_activation2 - " + "{{ trigger.platform}} - " + "{{ trigger.event.event_type}} - " + "{{ trigger.event.data.command_class }}" + ) + }, + }, + }, + ] + }, + ) + + # Publish fake Scene Activation CC value notification + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Scene Activation", + "commandClass": 43, + "endpoint": 0, + "property": "sceneId", + "propertyName": "sceneId", + "value": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "min": 1, + "max": 255, + "label": "Scene ID", + }, + "ccVersion": 1, + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[0].data[ + "some" + ] == "event.value_notification.scene_activation - device - zwave_js_value_notification - {}".format( + CommandClass.SCENE_ACTIVATION + ) + assert calls[1].data[ + "some" + ] == "event.value_notification.scene_activation2 - device - zwave_js_value_notification - {}".format( + CommandClass.SCENE_ACTIVATION + ) + + +async def test_get_trigger_capabilities_scene_activation_value_notification( + hass, client, hank_binary_switch, integration +): + """Test we get the expected capabilities from a value_notification.scene_activation trigger.""" + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "type": "event.value_notification.scene_activation", + "device_id": device.id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "property": "sceneId", + "property_key": None, + "endpoint": 0, + "subtype": "Endpoint 0", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "value", + "optional": True, + "type": "integer", + "valueMin": 1, + "valueMax": 255, + } + ] + + +async def test_failure_scenarios(hass, client, hank_binary_switch, integration): + """Test failure scenarios.""" + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, {"type": "failed.test", "device_id": "invalid_device_id"}, None, {} + ) + + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + {"type": "event.failed_type", "device_id": "invalid_device_id"}, + None, + {}, + ) + + dev_reg = async_get_dev_reg(hass) + device = async_entries_for_config_entry(dev_reg, integration.entry_id)[0] + + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, {"type": "failed.test", "device_id": device.id}, None, {} + ) + + with pytest.raises(HomeAssistantError): + await async_attach_trigger( + hass, + {"type": "event.failed_type", "device_id": device.id}, + None, + {}, + ) + + with patch( + "homeassistant.components.zwave_js.device_trigger.async_get_node_from_device_id", + return_value=None, + ), patch( + "homeassistant.components.zwave_js.helpers.get_zwave_value_from_config", + return_value=None, + ): + assert ( + await async_get_trigger_capabilities( + hass, {"type": "failed.test", "device_id": "invalid_device_id"} + ) + == {} + ) + + with pytest.raises(HomeAssistantError): + async_get_node_status_sensor_entity_id(hass, "invalid_device_id") diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 2d9cf06b095..fa3c73a9a42 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_RGBW_COLOR, ATTR_SUPPORTED_COLOR_MODES, + ATTR_TRANSITION, SUPPORT_TRANSITION, ) from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON @@ -62,12 +63,47 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 255 client.async_send_command.reset_mock() + # Test turning on with transition + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, ATTR_TRANSITION: 10}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + }, + } + assert args["value"] == 255 + assert args["options"]["transitionDuration"] == "10s" + + client.async_send_command.reset_mock() + # Test brightness update from value updated event event = Event( type="value updated", @@ -133,9 +169,49 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 50 + assert args["options"]["transitionDuration"] == "default" + + client.async_send_command.reset_mock() + + # Test turning on with brightness and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_BRIGHTNESS: 129, + ATTR_TRANSITION: 20, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 39 + assert args["valueId"] == { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + "propertyName": "targetValue", + "metadata": { + "label": "Target value", + "max": 99, + "min": 0, + "type": "number", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + }, + } + assert args["value"] == 50 + assert args["options"]["transitionDuration"] == "20s" client.async_send_command.reset_mock() @@ -256,6 +332,23 @@ async def test_light(hass, client, bulb_6_multi_color, integration): client.async_send_command.reset_mock() + # Test turning on with rgb color and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_RGB_COLOR: (128, 76, 255), + ATTR_TRANSITION: 20, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 6 + args = client.async_send_command.call_args_list[5][0][0] + assert args["options"]["transitionDuration"] == "20s" + client.async_send_command.reset_mock() + # Test turning on with color temp await hass.services.async_call( "light", @@ -377,6 +470,24 @@ async def test_light(hass, client, bulb_6_multi_color, integration): client.async_send_command.reset_mock() + # Test turning on with color temp and transition + await hass.services.async_call( + "light", + "turn_on", + { + "entity_id": BULB_6_MULTI_COLOR_LIGHT_ENTITY, + ATTR_COLOR_TEMP: 170, + ATTR_TRANSITION: 35, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 6 + args = client.async_send_command.call_args_list[5][0][0] + assert args["options"]["transitionDuration"] == "35s" + + client.async_send_command.reset_mock() + # Test turning off await hass.services.async_call( "light", @@ -403,6 +514,7 @@ async def test_light(hass, client, bulb_6_multi_color, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, } assert args["value"] == 0 @@ -457,6 +569,7 @@ async def test_rgbw_light(hass, client, zen_31, integration): "type": "any", "readable": True, "writeable": True, + "valueChangeOptions": ["transitionDuration"], }, "value": {"blue": 70, "green": 159, "red": 255, "warmWhite": 141}, } @@ -480,6 +593,7 @@ async def test_rgbw_light(hass, client, zen_31, integration): "readable": True, "writeable": True, "label": "Target value", + "valueChangeOptions": ["transitionDuration"], }, "value": 59, } diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 9ddc7abdd88..3727ab9d288 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS lock platform.""" from zwave_js_server.const import ATTR_CODE_SLOT, ATTR_USERCODE from zwave_js_server.event import Event +from zwave_js_server.model.node import NodeStatus from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, @@ -12,9 +13,14 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_CLEAR_LOCK_USERCODE, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNLOCKED, +) -SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt" +from .common import SCHLAGE_BE469_LOCK_ENTITY async def test_door_lock(hass, client, lock_schlage_be469, integration): @@ -203,3 +209,16 @@ async def test_door_lock(hass, client, lock_schlage_be469, integration): "value": 1, } assert args["value"] == 0 + + event = Event( + type="dead", + data={ + "source": "node", + "event": "dead", + "nodeId": 20, + }, + ) + node.receive_event(event) + + assert node.status == NodeStatus.DEAD + assert hass.states.get(SCHLAGE_BE469_LOCK_ENTITY).state == STATE_UNAVAILABLE diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index fc6d274235d..04583559421 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,11 +1,25 @@ """Test the Z-Wave JS sensor platform.""" +from unittest.mock import patch + from zwave_js_server.event import Event +from homeassistant.components.sensor import ATTR_LAST_RESET, STATE_CLASS_MEASUREMENT +from homeassistant.components.zwave_js.const import ( + ATTR_METER_TYPE, + ATTR_VALUE, + DOMAIN, + SERVICE_RESET_METER, +) from homeassistant.const import ( + ATTR_ENTITY_ID, + DEVICE_CLASS_CURRENT, DEVICE_CLASS_ENERGY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, @@ -14,11 +28,19 @@ from homeassistant.helpers import entity_registry as er from .common import ( AIR_TEMPERATURE_SENSOR, + BASIC_SENSOR, + CURRENT_SENSOR, + DATETIME_LAST_RESET, + DATETIME_ZERO, ENERGY_SENSOR, HUMIDITY_SENSOR, ID_LOCK_CONFIG_PARAMETER_SENSOR, + INDICATOR_SENSOR, + METER_ENERGY_SENSOR, + METER_VOLTAGE_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, + VOLTAGE_SENSOR, ) @@ -47,6 +69,7 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.0" assert state.attributes["unit_of_measurement"] == POWER_WATT assert state.attributes["device_class"] == DEVICE_CLASS_POWER + assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT state = hass.states.get(ENERGY_SENSOR) @@ -54,6 +77,21 @@ async def test_energy_sensors(hass, hank_binary_switch, integration): assert state.state == "0.16" assert state.attributes["unit_of_measurement"] == ENERGY_KILO_WATT_HOUR assert state.attributes["device_class"] == DEVICE_CLASS_ENERGY + assert state.attributes["state_class"] == STATE_CLASS_MEASUREMENT + + state = hass.states.get(VOLTAGE_SENSOR) + + assert state + assert state.state == "122.96" + assert state.attributes["unit_of_measurement"] == ELECTRIC_POTENTIAL_VOLT + assert state.attributes["device_class"] == DEVICE_CLASS_VOLTAGE + + state = hass.states.get(CURRENT_SENSOR) + + assert state + assert state.state == "0.0" + assert state.attributes["unit_of_measurement"] == ELECTRIC_CURRENT_AMPERE + assert state.attributes["device_class"] == DEVICE_CLASS_CURRENT async def test_disabled_notification_sensor(hass, multisensor_6, integration): @@ -81,6 +119,28 @@ async def test_disabled_notification_sensor(hass, multisensor_6, integration): assert state.attributes["value"] == 8 +async def test_disabled_indcator_sensor( + hass, climate_radio_thermostat_ct100_plus, integration +): + """Test sensor is created from Indicator CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(INDICATOR_SENSOR) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + +async def test_disabled_basic_sensor(hass, ge_in_wall_dimmer_switch, integration): + """Test sensor is created from Basic CC and is disabled.""" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_SENSOR) + + assert entity_entry + assert entity_entry.disabled + assert entity_entry.disabled_by == er.DISABLED_INTEGRATION + + async def test_config_parameter_sensor(hass, lock_id_lock_as_id150, integration): """Test config parameter sensor is created.""" ent_reg = er.async_get(hass) @@ -131,3 +191,97 @@ async def test_node_status_sensor(hass, lock_id_lock_as_id150, integration): ) node.receive_event(event) assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + + +async def test_reset_meter( + hass, + client, + aeon_smart_switch_6, + integration, +): + """Test reset_meter service.""" + client.async_send_command.return_value = {} + client.async_send_command_no_wait.return_value = {} + + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + + # Validate that the sensor last reset is starting from nothing + assert ( + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_ZERO.isoformat() + ) + + # Test successful meter reset call, patching utcnow so we can make sure the last + # reset gets updated + with patch("homeassistant.util.dt.utcnow", return_value=DATETIME_LAST_RESET): + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, + }, + blocking=True, + ) + + assert ( + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_LAST_RESET.isoformat() + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == aeon_smart_switch_6.node_id + assert args["endpoint"] == 0 + assert args["args"] == [] + + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + + client.async_send_command_no_wait.reset_mock() + + # Test successful meter reset call with options + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + { + ATTR_ENTITY_ID: METER_ENERGY_SENSOR, + ATTR_METER_TYPE: 1, + ATTR_VALUE: 2, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "endpoint.invoke_cc_api" + assert args["nodeId"] == aeon_smart_switch_6.node_id + assert args["endpoint"] == 0 + assert args["args"] == [{"type": 1, "targetValue": 2}] + + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes + + client.async_send_command_no_wait.reset_mock() + + +async def test_restore_last_reset( + hass, + client, + aeon_smart_switch_6, + restore_last_reset, + integration, +): + """Test restoring last_reset on setup.""" + assert ( + hass.states.get(METER_ENERGY_SENSOR).attributes[ATTR_LAST_RESET] + == DATETIME_LAST_RESET.isoformat() + ) + + # Validate that non accumulating meter does not have a last reset attribute + + assert ATTR_LAST_RESET not in hass.states.get(METER_VOLTAGE_SENSOR).attributes diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index dfc7ddaa85d..3ee656e40c0 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -11,7 +11,9 @@ from homeassistant.components.zwave_js.const import ( ATTR_CONFIG_PARAMETER, ATTR_CONFIG_PARAMETER_BITMASK, ATTR_CONFIG_VALUE, + ATTR_OPTIONS, ATTR_PROPERTY, + ATTR_PROPERTY_KEY, ATTR_REFRESH_ALL_VALUES, ATTR_VALUE, ATTR_WAIT_FOR_RESULT, @@ -31,8 +33,11 @@ from homeassistant.helpers.device_registry import ( from homeassistant.helpers.entity_registry import async_get as async_get_ent_reg from .common import ( + AEON_SMART_SWITCH_LIGHT_ENTITY, AIR_TEMPERATURE_SENSOR, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, CLIMATE_DANFOSS_LC13_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, CLIMATE_RADIO_THERMOSTAT_ENTITY, ) @@ -261,31 +266,7 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): } assert args["value"] == 1 - # Test that an invalid entity ID raises a MultipleInvalid - with pytest.raises(vol.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_CONFIG_PARAMETER, - { - ATTR_ENTITY_ID: "sensor.fake_entity", - ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", - ATTR_CONFIG_VALUE: "Fahrenheit", - }, - blocking=True, - ) - - # Test that an invalid device ID raises a MultipleInvalid - with pytest.raises(vol.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_CONFIG_PARAMETER, - { - ATTR_DEVICE_ID: "fake_device_id", - ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", - ATTR_CONFIG_VALUE: "Fahrenheit", - }, - blocking=True, - ) + client.async_send_command_no_wait.reset_mock() # Test that we can't include a bitmask value if parameter is a string with pytest.raises(vol.Invalid): @@ -308,36 +289,10 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): identifiers={("test", "test")}, ) - # Test that a non Z-Wave JS device raises a MultipleInvalid - with pytest.raises(vol.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_CONFIG_PARAMETER, - { - ATTR_DEVICE_ID: non_zwave_js_device.id, - ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", - ATTR_CONFIG_VALUE: "Fahrenheit", - }, - blocking=True, - ) - zwave_js_device_with_invalid_node_id = dev_reg.async_get_or_create( config_entry_id=integration.entry_id, identifiers={(DOMAIN, "500-500")} ) - # Test that a Z-Wave JS device with an invalid node ID raises a MultipleInvalid - with pytest.raises(vol.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_CONFIG_PARAMETER, - { - ATTR_DEVICE_ID: zwave_js_device_with_invalid_node_id.id, - ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", - ATTR_CONFIG_VALUE: "Fahrenheit", - }, - blocking=True, - ) - non_zwave_js_entity = ent_reg.async_get_or_create( "test", "sensor", @@ -346,18 +301,59 @@ async def test_set_config_parameter(hass, client, multisensor_6, integration): config_entry=non_zwave_js_config_entry, ) - # Test that a non Z-Wave JS entity raises a MultipleInvalid - with pytest.raises(vol.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SET_CONFIG_PARAMETER, - { - ATTR_ENTITY_ID: non_zwave_js_entity.entity_id, - ATTR_CONFIG_PARAMETER: "Temperature Threshold (Unit)", - ATTR_CONFIG_VALUE: "Fahrenheit", - }, - blocking=True, - ) + # Test that a Z-Wave JS device with an invalid node ID, non Z-Wave JS entity, + # non Z-Wave JS device, invalid device_id, and invalid node_id gets filtered out. + await hass.services.async_call( + DOMAIN, + SERVICE_SET_CONFIG_PARAMETER, + { + ATTR_ENTITY_ID: [ + AIR_TEMPERATURE_SENSOR, + non_zwave_js_entity.entity_id, + "sensor.fake", + ], + ATTR_DEVICE_ID: [ + zwave_js_device_with_invalid_node_id.id, + non_zwave_js_device.id, + "fake_device_id", + ], + ATTR_CONFIG_PARAMETER: 102, + ATTR_CONFIG_PARAMETER_BITMASK: "0x01", + ATTR_CONFIG_VALUE: 1, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClassName": "Configuration", + "commandClass": 112, + "endpoint": 0, + "property": 102, + "propertyName": "Group 2: Send battery reports", + "propertyKey": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "valueSize": 4, + "min": 0, + "max": 1, + "default": 1, + "format": 0, + "allowManualEntry": True, + "label": "Group 2: Send battery reports", + "description": "Include battery information in periodic reports to Group 2", + "isFromConfig": True, + }, + "value": 0, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() # Test that when a device is awake, we call async_send_command instead of # async_send_command_no_wait @@ -768,11 +764,51 @@ async def test_set_value(hass, client, climate_danfoss_lc_13, integration): ) +async def test_set_value_options(hass, client, aeon_smart_switch_6, integration): + """Test set_value service with options.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: AEON_SMART_SWITCH_LIGHT_ENTITY, + ATTR_COMMAND_CLASS: 37, + ATTR_PROPERTY: "targetValue", + ATTR_VALUE: 2, + ATTR_OPTIONS: {"transitionDuration": 1}, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == aeon_smart_switch_6.node_id + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": True, + "writeable": True, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + }, + } + assert args["value"] == 2 + assert args["options"] == {"transitionDuration": 1} + + client.async_send_command.reset_mock() + + async def test_multicast_set_value( hass, client, climate_danfoss_lc_13, - climate_radio_thermostat_ct100_plus_different_endpoints, + climate_eurotronic_spirit_z, integration, ): """Test multicast_set_value service.""" @@ -783,10 +819,11 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, @@ -796,12 +833,13 @@ async def test_multicast_set_value( args = client.async_send_command.call_args[0][0] assert args["command"] == "multicast_group.set_value" assert args["nodeIDs"] == [ - climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + climate_eurotronic_spirit_z.node_id, climate_danfoss_lc_13.node_id, ] assert args["valueId"] == { - "commandClass": 117, - "property": "local", + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, } assert args["value"] == 2 @@ -814,10 +852,11 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: "0x2", }, blocking=True, @@ -827,12 +866,13 @@ async def test_multicast_set_value( args = client.async_send_command.call_args[0][0] assert args["command"] == "multicast_group.set_value" assert args["nodeIDs"] == [ - climate_radio_thermostat_ct100_plus_different_endpoints.node_id, + climate_eurotronic_spirit_z.node_id, climate_danfoss_lc_13.node_id, ] assert args["valueId"] == { - "commandClass": 117, - "property": "local", + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, } assert args["value"] == 2 @@ -844,8 +884,9 @@ async def test_multicast_set_value( SERVICE_MULTICAST_SET_VALUE, { ATTR_BROADCAST: True, - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, @@ -855,26 +896,33 @@ async def test_multicast_set_value( args = client.async_send_command.call_args[0][0] assert args["command"] == "broadcast_node.set_value" assert args["valueId"] == { - "commandClass": 117, - "property": "local", + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, } assert args["value"] == 2 client.async_send_command.reset_mock() - # Test sending one node without broadcast fails - with pytest.raises(vol.Invalid): - await hass.services.async_call( - DOMAIN, - SERVICE_MULTICAST_SET_VALUE, - { - ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", - ATTR_VALUE: 2, - }, - blocking=True, - ) + # Test sending one node without broadcast uses the node.set_value command instead + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, + ATTR_VALUE: 2, + }, + blocking=True, + ) + + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] + assert args["command"] == "node.set_value" + + client.async_send_command_no_wait.reset_mock() # Test no device, entity, or broadcast flag raises error with pytest.raises(vol.Invalid): @@ -882,8 +930,9 @@ async def test_multicast_set_value( DOMAIN, SERVICE_MULTICAST_SET_VALUE, { - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, @@ -899,10 +948,11 @@ async def test_multicast_set_value( { ATTR_ENTITY_ID: [ CLIMATE_DANFOSS_LC13_ENTITY, - CLIMATE_RADIO_THERMOSTAT_ENTITY, + CLIMATE_EUROTRONICS_SPIRIT_Z_ENTITY, ], - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, @@ -926,8 +976,9 @@ async def test_multicast_set_value( CLIMATE_DANFOSS_LC13_ENTITY, ], ATTR_DEVICE_ID: "fake_device_id", - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, @@ -943,14 +994,58 @@ async def test_multicast_set_value( SERVICE_MULTICAST_SET_VALUE, { ATTR_BROADCAST: True, - ATTR_COMMAND_CLASS: 117, - ATTR_PROPERTY: "local", + ATTR_COMMAND_CLASS: 67, + ATTR_PROPERTY: "setpoint", + ATTR_PROPERTY_KEY: 1, ATTR_VALUE: 2, }, blocking=True, ) +async def test_multicast_set_value_options( + hass, + client, + bulb_6_multi_color, + light_color_null_values, + integration, +): + """Test multicast_set_value service with options.""" + await hass.services.async_call( + DOMAIN, + SERVICE_MULTICAST_SET_VALUE, + { + ATTR_ENTITY_ID: [ + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + "light.repeater", + ], + ATTR_COMMAND_CLASS: 51, + ATTR_PROPERTY: "targetColor", + ATTR_PROPERTY_KEY: 2, + ATTR_VALUE: 2, + ATTR_OPTIONS: {"transitionDuration": 1}, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "multicast_group.set_value" + assert args["nodeIDs"] == [ + bulb_6_multi_color.node_id, + light_color_null_values.node_id, + ] + assert args["valueId"] == { + "commandClass": 51, + "property": "targetColor", + "propertyKey": 2, + } + assert args["value"] == 2 + assert args["options"] == {"transitionDuration": 1} + + client.async_send_command.reset_mock() + + async def test_ping( hass, client, diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py new file mode 100644 index 00000000000..937b2c0fa67 --- /dev/null +++ b/tests/components/zwave_js/test_siren.py @@ -0,0 +1,146 @@ +"""Test the Z-Wave JS siren platform.""" +from zwave_js_server.event import Event + +from homeassistant.components.siren import ATTR_TONE, ATTR_VOLUME_LEVEL +from homeassistant.const import STATE_OFF, STATE_ON + +SIREN_ENTITY = "siren.indoor_siren_6_2" + +TONE_ID_VALUE_ID = { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": True, + "writeable": True, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default", + }, + "valueChangeOptions": ["volume"], + }, +} + + +async def test_siren(hass, client, aeotec_zw164_siren, integration): + """Test the siren entity.""" + node = aeotec_zw164_siren + state = hass.states.get(SIREN_ENTITY) + + assert state + assert state.state == STATE_OFF + + # Test turn on with default + await hass.services.async_call( + "siren", + "turn_on", + {"entity_id": SIREN_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == TONE_ID_VALUE_ID + assert args["value"] == 255 + + client.async_send_command.reset_mock() + + # Test turn on with specific tone name and volume level + await hass.services.async_call( + "siren", + "turn_on", + { + "entity_id": SIREN_ENTITY, + ATTR_TONE: "01DING~1 (5 sec)", + ATTR_VOLUME_LEVEL: 0.5, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == TONE_ID_VALUE_ID + assert args["value"] == 1 + assert args["options"] == {"volume": 50} + + client.async_send_command.reset_mock() + + # Test turn off + await hass.services.async_call( + "siren", + "turn_off", + {"entity_id": SIREN_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == TONE_ID_VALUE_ID + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + # Test value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Sound Switch", + "commandClass": 121, + "endpoint": 2, + "property": "toneId", + "newValue": 255, + "prevValue": 0, + "propertyName": "toneId", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(SIREN_ENTITY) + assert state.state == STATE_ON diff --git a/tests/conftest.py b/tests/conftest.py index 1f5ffc80d0d..ce8e244f420 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -610,7 +610,7 @@ def enable_statistics(): @pytest.fixture -def hass_recorder(enable_statistics): +def hass_recorder(enable_statistics, hass_storage): """Home Assistant fixture with in-memory recorder.""" hass = get_test_home_assistant() stats = recorder.Recorder.async_hourly_statistics if enable_statistics else None diff --git a/tests/fixtures/august/get_activity.jammed.json b/tests/fixtures/august/get_activity.jammed.json new file mode 100644 index 00000000000..be5b9dfa4eb --- /dev/null +++ b/tests/fixtures/august/get_activity.jammed.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "jammed", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.locking.json b/tests/fixtures/august/get_activity.locking.json new file mode 100644 index 00000000000..c1f07e47312 --- /dev/null +++ b/tests/fixtures/august/get_activity.locking.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "locking", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.unlocking.json b/tests/fixtures/august/get_activity.unlocking.json new file mode 100644 index 00000000000..788a69164aa --- /dev/null +++ b/tests/fixtures/august/get_activity.unlocking.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "unlocking", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/homekit_controller/koogeek_sw2.json b/tests/fixtures/homekit_controller/koogeek_sw2.json new file mode 100644 index 00000000000..b7807bfb6a7 --- /dev/null +++ b/tests/fixtures/homekit_controller/koogeek_sw2.json @@ -0,0 +1,265 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Koogeek-SW2-187A91" + }, + { + "format": "string", + "iid": 3, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Koogeek" + }, + { + "format": "string", + "iid": 4, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "KH02CN" + }, + { + "format": "string", + "iid": 5, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "CNNT061751001372" + }, + { + "format": "bool", + "iid": 6, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "1.0.3" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "ev": true, + "format": "bool", + "iid": 9, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": false + }, + { + "format": "string", + "iid": 10, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Switch 1" + } + ], + "iid": 8, + "primary": true, + "stype": "switch", + "type": "00000049-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "ev": true, + "format": "bool", + "iid": 12, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": false + }, + { + "format": "string", + "iid": 13, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Switch 2" + } + ], + "iid": 11, + "primary": true, + "stype": "switch", + "type": "00000049-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 15, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "custom service" + }, + { + "description": "Current Time", + "format": "int", + "iid": 16, + "perms": [ + "pr" + ], + "type": "7BBBA961-EB2D-11E5-A837-0800200C9A66", + "value": 1599731035 + }, + { + "description": "Time Zone", + "format": "int", + "iid": 17, + "perms": [ + "pr", + "pw" + ], + "type": "7BBBA980-EB2D-11E5-A837-0800200C9A66", + "value": 16 + }, + { + "description": "Current Power", + "ev": false, + "format": "int", + "iid": 18, + "perms": [ + "pr", + "ev" + ], + "type": "7BBBA96E-EB2D-11E5-A837-0800200C9A66", + "value": 0 + }, + { + "description": "Power Consumption Today", + "format": "data", + "iid": 19, + "perms": [ + "pr" + ], + "type": "7BBBA96F-EB2D-11E5-A837-0800200C9A66", + "value": "9pcBAL4GAAC1BgAAtgYAAELhAABXIwAAtgYAAKcGAABHOQAA1aMAAP//////////////////////////////////////////////////////////////////////////" + }, + { + "description": "Power Consumption last 2 Month", + "format": "data", + "iid": 20, + "perms": [ + "pr" + ], + "type": "7BBBA972-EB2D-11E5-A837-0800200C9A66", + "value": "/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCFAEA5HkIADGbAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + { + "description": "Power Consumption last 12 Month", + "format": "data", + "iid": 21, + "perms": [ + "pr" + ], + "type": "7BBBA970-EB2D-11E5-A837-0800200C9A66", + "value": "//////////////////////////////////////////+XKQ0A////////////////" + } + ], + "hidden": true, + "iid": 14, + "stype": "Unknown Service: 7BBBA977-EB2D-11E5-A837-0800200C9A66", + "type": "7BBBA977-EB2D-11E5-A837-0800200C9A66" + }, + { + "characteristics": [ + { + "description": "FW Upgrade supported types", + "format": "string", + "iid": 23, + "perms": [ + "pr", + "hd" + ], + "type": "151909D2-3802-11E4-916C-0800200C9A66", + "value": "url,data" + }, + { + "description": "FW Upgrade URL", + "format": "string", + "iid": 24, + "maxLen": 256, + "perms": [ + "pw", + "hd" + ], + "type": "151909D1-3802-11E4-916C-0800200C9A66" + }, + { + "description": "FW Upgrade Status", + "ev": false, + "format": "int", + "iid": 25, + "perms": [ + "pr", + "ev", + "hd" + ], + "type": "151909D6-3802-11E4-916C-0800200C9A66", + "value": 0 + }, + { + "description": "FW Upgrade Data", + "format": "data", + "iid": 26, + "perms": [ + "pw", + "hd" + ], + "type": "151909D7-3802-11E4-916C-0800200C9A66" + } + ], + "hidden": true, + "iid": 22, + "stype": "Unknown Service: 151909D0-3802-11E4-916C-0800200C9A66", + "type": "151909D0-3802-11E4-916C-0800200C9A66" + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/homekit_controller/mysa_living.json b/tests/fixtures/homekit_controller/mysa_living.json new file mode 100644 index 00000000000..da26b654fe5 --- /dev/null +++ b/tests/fixtures/homekit_controller/mysa_living.json @@ -0,0 +1,250 @@ +[ + { + "aid": 1, + "primary": true, + "services": [ + { + "type": "0000004A-0000-1000-8000-0026BB765291", + "primary": true, + "iid": 20, + "characteristics": [ + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Thermostat", + "perms": [ + "pr" + ], + "iid": 24 + }, + { + "type": "00000010-0000-1000-8000-0026BB765291", + "format": "float", + "minValue": 0, + "maxValue": 100, + "stepValue": 1, + "value": 40, + "iid": 27, + "unit": "percentage", + "perms": [ + "pr", + "ev" + ] + }, + { + "type": "0000000F-0000-1000-8000-0026BB765291", + "value": 0, + "minValue": 0, + "maxValue": 2, + "stepValue": 1, + "format": "uint8", + "perms": [ + "pr", + "ev" + ], + "iid": 21 + }, + { + "type": "00000033-0000-1000-8000-0026BB765291", + "value": 0, + "minValue": 0, + "maxValue": 3, + "stepValue": 1, + "format": "uint8", + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 22 + }, + { + "type": "00000011-0000-1000-8000-0026BB765291", + "value": 24.1, + "minValue": 0, + "maxValue": 100, + "stepValue": 0.1, + "unit": "celsius", + "format": "float", + "perms": [ + "pr", + "ev" + ], + "iid": 25 + }, + { + "type": "00000035-0000-1000-8000-0026BB765291", + "value": 22, + "minValue": 5, + "maxValue": 30, + "stepValue": 0.1, + "unit": "celsius", + "format": "float", + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 23 + }, + { + "type": "00000036-0000-1000-8000-0026BB765291", + "format": "uint8", + "minValue": 0, + "maxValue": 1, + "stepValue": 1, + "value": 0, + "iid": 26, + "perms": [ + "pr", + "pw", + "ev" + ] + } + ] + }, + { + "type": "0000003E-0000-1000-8000-0026BB765291", + "iid": 1, + "characteristics": [ + { + "type": "00000014-0000-1000-8000-0026BB765291", + "perms": [ + "pw" + ], + "iid": 2, + "format": "bool" + }, + { + "type": "00000020-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Empowered Homes Inc.", + "perms": [ + "pr" + ], + "iid": 3 + }, + { + "type": "00000021-0000-1000-8000-0026BB765291", + "format": "string", + "value": "v1", + "perms": [ + "pr" + ], + "iid": 4 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Mysa-85dda9", + "perms": [ + "pr" + ], + "iid": 5 + }, + { + "type": "00000030-0000-1000-8000-0026BB765291", + "format": "string", + "value": "AAAAAAA000", + "perms": [ + "pr" + ], + "iid": 6 + }, + { + "type": "00000052-0000-1000-8000-0026BB765291", + "format": "string", + "value": "2.8.1", + "perms": [ + "pr" + ], + "iid": 7 + }, + { + "hidden": true, + "type": "22280E2C-9B79-43BD-8370-5A8F67777B29", + "format": "string", + "value": "b4e62d85dda9", + "perms": [ + "pr" + ], + "iid": 8 + } + ] + }, + { + "type": "000000A2-0000-1000-8000-0026BB765291", + "iid": 10, + "characteristics": [ + { + "type": "00000037-0000-1000-8000-0026BB765291", + "format": "string", + "value": "1.1.0", + "perms": [ + "pr" + ], + "iid": 11 + } + ] + }, + { + "type": "00000043-0000-1000-8000-0026BB765291", + "iid": 40, + "characteristics": [ + { + "type": "00000025-0000-1000-8000-0026BB765291", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "iid": 42 + }, + { + "type": "00000023-0000-1000-8000-0026BB765291", + "format": "string", + "value": "Display", + "perms": [ + "pr" + ], + "iid": 41 + }, + { + "type": "00000008-0000-1000-8000-0026BB765291", + "format": "int", + "minValue": 0, + "maxValue": 100, + "stepValue": 1, + "value": 0, + "iid": 43, + "unit": "percentage", + "perms": [ + "pr", + "pw", + "ev" + ] + } + ] + }, + { + "type": "3354EC82-AF38-4755-B4A4-4DB8E418F555", + "iid": 50, + "characteristics": [ + { + "hidden": true, + "type": "E71D8348-BB33-4C34-8C50-A64B1136EDD2", + "format": "bool", + "value": 0, + "perms": [ + "pr", + "pw" + ], + "iid": 51 + } + ] + } + ] + } +] \ No newline at end of file diff --git a/tests/fixtures/homekit_controller/vocolinc_flowerbud.json b/tests/fixtures/homekit_controller/vocolinc_flowerbud.json new file mode 100644 index 00000000000..012c03471f3 --- /dev/null +++ b/tests/fixtures/homekit_controller/vocolinc_flowerbud.json @@ -0,0 +1,467 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "bool", + "iid": 2, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 3, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "VOCOlinc" + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Flowerbud" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "VOCOlinc-Flowerbud-0d324b" + }, + { + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "AM01121849000327" + }, + { + "description": "", + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "3.121.2" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "0.1" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "rssi_report_switch", + "format": "bool", + "iid": 81, + "perms": [ + "pr", + "pw" + ], + "type": "D9959C8A-809A-4F75-92D7-71F630AC2925", + "value": 0 + }, + { + "description": "rssi_report_value", + "format": "uint8", + "iid": 82, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "8137182C-6904-4FB9-ADCC-61CECA85CE48", + "value": 0 + } + ], + "iid": 80, + "stype": "Unknown Service: C635EF5C-5BBC-4F96-B7DA-6669069A4B32", + "type": "C635EF5C-5BBC-4F96-B7DA-6669069A4B32" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 31, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "FLOWERBUD" + }, + { + "format": "uint8", + "iid": 32, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B0-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "format": "float", + "iid": 33, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "ev" + ], + "type": "00000010-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 45.0 + }, + { + "format": "uint8", + "iid": 34, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "000000B3-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "format": "uint8", + "iid": 35, + "maxValue": 1, + "minStep": 1, + "minValue": 1, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B4-0000-1000-8000-0026BB765291", + "value": 1 + }, + { + "format": "uint8", + "iid": 36, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "36158AC8-5191-4AE2-9EF5-1D6722E88E3D", + "value": 1 + }, + { + "description": "spray quantity", + "format": "uint8", + "iid": 38, + "maxValue": 5, + "minStep": 1, + "minValue": 1, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "69D52519-0A4E-4898-8335-4739F9116D0A", + "value": 5 + }, + { + "format": "float", + "iid": 39, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000CA-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100.0 + }, + { + "description": "humidifier_timer_setting", + "format": "data", + "iid": 40, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "F84B3138-E44F-49B9-AA91-9E1736C247C0", + "value": "AA==" + }, + { + "description": "humidifier_countdown", + "format": "data", + "iid": 41, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "43CE176B-2933-4034-98A7-AD215BEEBF2F", + "value": "AA==" + } + ], + "iid": 30, + "stype": "humidifier-dehumidifier", + "type": "000000BD-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 10, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Mood Light" + }, + { + "format": "bool", + "iid": 11, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": true + }, + { + "format": "int", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000008-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 50 + }, + { + "format": "float", + "iid": 13, + "maxValue": 360.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000013-0000-1000-8000-0026BB765291", + "unit": "arcdegrees", + "value": 120.0 + }, + { + "format": "float", + "iid": 14, + "maxValue": 100.0, + "minStep": 1.0, + "minValue": 0.0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "0000002F-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100.0 + }, + { + "description": "lb_timer_setting", + "format": "data", + "iid": 63, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "A30DFE91-271A-42A5-88BA-00E3FF5488AD", + "value": "AA==" + }, + { + "description": "light effect mode", + "format": "uint8", + "iid": 64, + "maxValue": 31, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "146889FC-7C42-429B-93AB-E80F79759E90", + "value": 0 + }, + { + "description": "light effect flag", + "format": "uint32", + "iid": 73, + "perms": [ + "pr" + ], + "type": "9D4B479D-9EFB-4739-98F3-B33E6543BF7B", + "value": 7 + }, + { + "description": "flashing mode", + "format": "data", + "iid": 65, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "2C42B339-6EC9-4ED5-8DBF-FFCCC721B144", + "value": "AA==" + }, + { + "description": "smoothing mode", + "format": "data", + "iid": 66, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "A3663C89-DC18-42EF-8297-910A4C0C9B61", + "value": "AA==" + }, + { + "description": "breathing mode", + "format": "data", + "iid": 67, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "6533B15C-AECB-455F-8896-20B125390F61", + "value": "AA==" + } + ], + "iid": 9, + "stype": "lightbulb", + "type": "00000043-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "time_zone", + "format": "int", + "iid": 50, + "maxValue": 1400, + "minStep": 1, + "minValue": -1200, + "perms": [ + "pr", + "pw" + ], + "type": "38396B8E-161B-4A77-AF3F-C4DAC0BE9B74", + "value": 0 + }, + { + "description": "hour_date_time", + "format": "int", + "iid": 51, + "perms": [ + "pr", + "pw" + ], + "type": "71216CD3-209E-40CC-BEA0-71A2A9458E13", + "value": 0 + } + ], + "iid": 48, + "stype": "Unknown Service: 961EBB65-A1E3-4F34-BD31-86552706FE40", + "type": "961EBB65-A1E3-4F34-BD31-86552706FE40" + }, + { + "characteristics": [ + { + "description": "fm_upgrade_status", + "format": "int", + "iid": 21, + "perms": [ + "pr", + "ev" + ], + "type": "49DDDE07-C3FA-499E-8055-58E154E04F34", + "value": 0 + }, + { + "description": "fm_upgrade_url", + "format": "string", + "iid": 22, + "maxLen": 256, + "perms": [ + "pw" + ], + "type": "4C203E30-EB25-466D-9980-C6C2E14BF6AA" + } + ], + "hidden": true, + "iid": 20, + "stype": "Unknown Service: 3138B537-E830-4F52-90A7-D6FDB000BF97", + "type": "3138B537-E830-4F52-90A7-D6FDB000BF97" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 24, + "perms": [ + "pr" + ], + "type": "00000037-0000-1000-8000-0026BB765291", + "value": "1.1.0" + } + ], + "iid": 23, + "stype": "service", + "type": "000000A2-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index 4579fad30ba..8462295cbc1 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -7037,7 +7037,7 @@ "dutyCycle": false, "homeId": "00000000-0000-0000-0000-000000000001", "id": "00000000-0000-0000-0000-000000000016", - "ignorableDevices": [], + "ignorableDeviceChannels": [], "label": "INTERNAL", "lastStatusUpdate": 1524515489257, "lowBat": false, @@ -7373,7 +7373,7 @@ "dutyCycle": false, "homeId": "00000000-0000-0000-0000-000000000001", "id": "00000000-0000-0000-0000-000000000005", - "ignorableDevices": [], + "ignorableDeviceChannels": [], "label": "EXTERNAL", "lastStatusUpdate": 1524516526498, "lowBat": false, @@ -8363,7 +8363,10 @@ "activationInProgress": false, "active": true, "alarmActive": false, - "alarmEventDeviceId": "3014F7110000000000000007", + "alarmEventDeviceChannel": { + "channelIndex": 1, + "deviceId": "3014F7110000000000000007" + }, "alarmEventTimestamp": 1524504122047, "alarmSecurityJournalEntryType": "SENSOR_EVENT", "functionalGroups": [ diff --git a/tests/fixtures/insteon/aldb_data.json b/tests/fixtures/insteon/aldb_data.json new file mode 100644 index 00000000000..2cab1dd5050 --- /dev/null +++ b/tests/fixtures/insteon/aldb_data.json @@ -0,0 +1,67 @@ +{ + "4095": { + "memory": 4095, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4087": { + "memory": 4087, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 1, + "target": "aaaaaa", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4079": { + "memory": 4079, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 0, + "target": "111111", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4071": { + "memory": 4071, + "in_use": true, + "controller": true, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 2, + "target": "222222", + "data1": 0, + "data2": 0, + "data3": 0 + }, + "4063": { + "memory": 4063, + "in_use": true, + "controller": false, + "high_water_mark": false, + "bit5": true, + "bit4": false, + "group": 3, + "target": "333333", + "data1": 0, + "data2": 0, + "data3": 0 + } +} \ No newline at end of file diff --git a/tests/fixtures/insteon/kpl_properties.json b/tests/fixtures/insteon/kpl_properties.json new file mode 100644 index 00000000000..1115428a073 --- /dev/null +++ b/tests/fixtures/insteon/kpl_properties.json @@ -0,0 +1,66 @@ +{ + "operating_flags": { + "program_lock_on": false, + "blink_on_tx_on": false, + "resume_dim_on": false, + "led_on": false, + "key_beep_on": false, + "rf_disable_on": false, + "powerline_disable_on": false, + "blink_on_error_on": false + }, + "properties": { + "led_dimming": 10, + "non_toggle_mask": 2, + "non_toggle_on_off_mask": 2, + "trigger_group_mask": 0, + "on_mask": 0, + "off_mask": 0, + "x10_house": 32, + "x10_unit": 32, + "ramp_rate": 28, + "on_level": 255, + "on_mask_2": 0, + "off_mask_2": 0, + "x10_house_2": 32, + "x10_unit_2": 32, + "ramp_rate_2": 0, + "on_level_2": 0, + "on_mask_3": 0, + "off_mask_3": 0, + "x10_house_3": 32, + "x10_unit_3": 32, + "ramp_rate_3": 0, + "on_level_3": 0, + "on_mask_4": 16, + "off_mask_4": 16, + "x10_house_4": 32, + "x10_unit_4": 32, + "ramp_rate_4": 0, + "on_level_4": 0, + "on_mask_5": 0, + "off_mask_5": 0, + "x10_house_5": 32, + "x10_unit_5": 32, + "ramp_rate_5": 0, + "on_level_5": 0, + "on_mask_6": 0, + "off_mask_6": 0, + "x10_house_6": 32, + "x10_unit_6": 32, + "ramp_rate_6": 0, + "on_level_6": 0, + "on_mask_7": 128, + "off_mask_7": 128, + "x10_house_7": 32, + "x10_unit_7": 32, + "ramp_rate_7": 0, + "on_level_7": 0, + "on_mask_8": 64, + "off_mask_8": 64, + "x10_house_8": 32, + "x10_unit_8": 2, + "ramp_rate_8": 98, + "on_level_8": 74 + } +} \ No newline at end of file diff --git a/tests/fixtures/mysensors/power_sensor_state.json b/tests/fixtures/mysensors/power_sensor_state.json new file mode 100644 index 00000000000..40fcc4e4c74 --- /dev/null +++ b/tests/fixtures/mysensors/power_sensor_state.json @@ -0,0 +1,21 @@ +{ + "1": { + "sensor_id": 1, + "children": { + "1": { + "id": 1, + "type": 13, + "description": "", + "values": { + "17": "1200" + } + } + }, + "type": 17, + "sketch_name": "Power Sensor", + "sketch_version": "1.0", + "battery_level": 0, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/fixtures/renault/battery_status_charging.json b/tests/fixtures/renault/battery_status_charging.json new file mode 100644 index 00000000000..dbde4597e93 --- /dev/null +++ b/tests/fixtures/renault/battery_status_charging.json @@ -0,0 +1,18 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-01-12T21:40:16Z", + "batteryLevel": 60, + "batteryTemperature": 20, + "batteryAutonomy": 141, + "batteryCapacity": 0, + "batteryAvailableEnergy": 31, + "plugStatus": 1, + "chargingStatus": 1.0, + "chargingRemainingTime": 145, + "chargingInstantaneousPower": 27 + } + } +} diff --git a/tests/fixtures/renault/battery_status_not_charging.json b/tests/fixtures/renault/battery_status_not_charging.json new file mode 100644 index 00000000000..750d0081ed9 --- /dev/null +++ b/tests/fixtures/renault/battery_status_not_charging.json @@ -0,0 +1,15 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "timestamp": "2020-11-17T09:06:48+01:00", + "batteryLevel": 50, + "batteryAutonomy": 128, + "batteryCapacity": 0, + "batteryAvailableEnergy": 0, + "plugStatus": 0, + "chargingStatus": -1.0 + } + } +} diff --git a/tests/fixtures/renault/charge_mode_always.json b/tests/fixtures/renault/charge_mode_always.json new file mode 100644 index 00000000000..6f146a2f72f --- /dev/null +++ b/tests/fixtures/renault/charge_mode_always.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "always" } + } +} diff --git a/tests/fixtures/renault/charge_mode_schedule.json b/tests/fixtures/renault/charge_mode_schedule.json new file mode 100644 index 00000000000..778994746ff --- /dev/null +++ b/tests/fixtures/renault/charge_mode_schedule.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "chargeMode": "schedule_mode" } + } +} diff --git a/tests/fixtures/renault/cockpit_ev.json b/tests/fixtures/renault/cockpit_ev.json new file mode 100644 index 00000000000..c5a390f3dda --- /dev/null +++ b/tests/fixtures/renault/cockpit_ev.json @@ -0,0 +1,9 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { + "totalMileage": 49114.27 + } + } +} diff --git a/tests/fixtures/renault/cockpit_fuel.json b/tests/fixtures/renault/cockpit_fuel.json new file mode 100644 index 00000000000..575a4236c19 --- /dev/null +++ b/tests/fixtures/renault/cockpit_fuel.json @@ -0,0 +1,11 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777123", + "attributes": { + "fuelAutonomy": 35.0, + "fuelQuantity": 3.0, + "totalMileage": 5566.78 + } + } +} diff --git a/tests/fixtures/renault/hvac_status.json b/tests/fixtures/renault/hvac_status.json new file mode 100644 index 00000000000..f48cbae68ae --- /dev/null +++ b/tests/fixtures/renault/hvac_status.json @@ -0,0 +1,7 @@ +{ + "data": { + "type": "Car", + "id": "VF1AAAAA555777999", + "attributes": { "externalTemperature": 8.0, "hvacStatus": "off" } + } +} diff --git a/tests/fixtures/renault/vehicle_captur_fuel.json b/tests/fixtures/renault/vehicle_captur_fuel.json new file mode 100644 index 00000000000..3aa854c61ea --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_fuel.json @@ -0,0 +1,108 @@ +{ + "accountId": "account-id-1", + "country": "LU", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "USER", + "garageBrand": "RENAULT", + "mileage": 346, + "startDate": "2020-06-12", + "createdDate": "2020-06-12T15:02:00.555432Z", + "lastModifiedDate": "2020-06-15T06:21:43.762467Z", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-06-15T06:20:39.107794Z", + "lastModifiedDate": "2020-06-15T06:20:39.107794Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "engineType": "H5H", + "engineRatio": "470", + "modelSCR": "CP1", + "deliveryCountry": { + "code": "BE", + "label": "BELGIQUE" + }, + "family": { + "code": "XJB", + "label": "FAMILLE B+X OVER", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "SANBAT", + "label": "SANS BATTERIE", + "group": "968" + }, + "radioType": { + "code": "NA406", + "label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2", + "group": "425" + }, + "registrationCountry": { + "code": "BE" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVA7", + "label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS", + "group": "427" + }, + "version": { + "code": "ITAMFHA 6TH" + }, + "energy": { + "code": "ESS", + "label": "ESSENCE", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-06-17", + "retrievedFromDhs": false, + "engineEnergyType": "OTHER", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_captur_phev.json b/tests/fixtures/renault/vehicle_captur_phev.json new file mode 100644 index 00000000000..03066c8238f --- /dev/null +++ b/tests/fixtures/renault/vehicle_captur_phev.json @@ -0,0 +1,110 @@ +{ + "accountId": "account-id-2", + "country": "IT", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777123", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "startDate": "2020-10-07", + "createdDate": "2020-10-07T09:17:44.692802Z", + "lastModifiedDate": "2021-03-28T10:44:01.139649Z", + "ownershipStartDate": "2020-09-30", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2020-10-08T17:36:39.445523Z", + "lastModifiedDate": "2020-10-08T17:36:39.445523Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777123", + "registrationDate": "2020-09-30", + "firstRegistrationDate": "2020-09-30", + "engineType": "H4M", + "engineRatio": "630", + "modelSCR": "", + "deliveryCountry": { + "code": "IT", + "label": "ITALY" + }, + "family": { + "code": "XJB", + "label": "B+X OVER FAMILY", + "group": "007" + }, + "tcu": { + "code": "AIVCT", + "label": "WITH AIVC CONNECTION UNIT", + "group": "E70" + }, + "navigationAssistanceLevel": { + "code": "", + "label": "", + "group": "" + }, + "battery": { + "code": "BT9AE1", + "label": "BATTERY BT9AE1", + "group": "968" + }, + "radioType": { + "code": "NA418", + "label": "FULL NAV DAB ETH - AUDI", + "group": "425" + }, + "registrationCountry": { + "code": "IT" + }, + "brand": { + "label": "RENAULT" + }, + "model": { + "code": "XJB1SU", + "label": "CAPTUR II", + "group": "971" + }, + "gearbox": { + "code": "BVH4", + "label": "HYBRID 4 SPEED GEARBOX", + "group": "427" + }, + "version": { + "code": "ITAMMHH 6UP" + }, + "energy": { + "code": "ESS", + "label": "PETROL", + "group": "019" + }, + "registrationNumber": "REG-NUMBER", + "vcd": "STANDA/XJB/HJB/EA3/MM/ESS/DG/TEMP/TR4X2/AFURGE/RV/ABS/SBARTO/CA02/TN/PBNCH/LAC/VT/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV01/SGAR02/BIYPC/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/CACBL3/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGSCHA/ITA01/APL03/FSTPO/ALOUC5/PART01/CMAR3P/FIPOU2/NA418/BVH4/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRFLY/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06U/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/5DHS/HYB06/010KWH/BT9AE1/VEC237/XJB1SU/NBT018/H4M/NOADR/DLIGM2/PGPRT2/FEUAR3/SCDVIT/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET05/SDANGM/ECOMOD/SSRCAR/AIVCT/AVGSI/TPQNW/TSGNE/2TON/ITPK4/MLEXP1/SPERTA/SSPERG/SPERTP/VOLNCH/SREACT/AVTSR1/SWALBO/DWGE01/AVC1A/VSPTA/1234Y/AEBS07/PRAHL/RRCAM", + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=HJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRV%2FSBARTO%2FCA02%2FTN%2FPBNCH%2FVT%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIYPC%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETRCR%2FREPNTC%2FLVAVIP%2FLVAREI%2FALOUC5%2FNA418%2FBVH4%2FECLHB4%2FRDIF10%2FCSRFLY%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB06%2FH4M%2FNOADR%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FSSCCPC%2FRCALL%2FMET05%2FSDANGM%2FSSRCAR%2FAVGSI%2FITPK4%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLNCH%2FSREACT%2FDWGE01%2FRRCAM&databaseId=b2b4fefb-d131-4f8f-9a24-4223c38bc710&bookmarkSet=CARPICKER&bookmark=EXT_34_RIGHT_FRONT&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + } + ], + "yearsOfMaintenance": 12, + "connectivityTechnology": "NONE", + "easyConnectStore": false, + "electrical": false, + "rlinkStore": false, + "deliveryDate": "2020-09-30", + "retrievedFromDhs": false, + "engineEnergyType": "PHEV", + "radioCode": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_40.json b/tests/fixtures/renault/vehicle_zoe_40.json new file mode 100644 index 00000000000..ab80d586652 --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_40.json @@ -0,0 +1,189 @@ +{ + "accountId": "account-id-1", + "country": "FR", + "vehicleLinks": [ + { + "brand": "RENAULT", + "vin": "VF1AAAAA555777999", + "status": "ACTIVE", + "linkType": "OWNER", + "garageBrand": "RENAULT", + "annualMileage": 16000, + "mileage": 26464, + "startDate": "2017-08-07", + "createdDate": "2019-05-23T21:38:16.409008Z", + "lastModifiedDate": "2020-11-17T08:41:40.497400Z", + "ownershipStartDate": "2017-08-01", + "cancellationReason": {}, + "connectedDriver": { + "role": "MAIN_DRIVER", + "createdDate": "2019-06-17T09:49:06.880627Z", + "lastModifiedDate": "2019-06-17T09:49:06.880627Z" + }, + "vehicleDetails": { + "vin": "VF1AAAAA555777999", + "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": "REG-NUMBER", + "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": "1234" + } + } + ] +} diff --git a/tests/fixtures/renault/vehicle_zoe_50.json b/tests/fixtures/renault/vehicle_zoe_50.json new file mode 100644 index 00000000000..560b2a2246a --- /dev/null +++ b/tests/fixtures/renault/vehicle_zoe_50.json @@ -0,0 +1,161 @@ +{ + "country": "GB", + "vehicleLinks": [ + { + "preferredDealer": { + "brand": "RENAULT", + "createdDate": "2019-05-23T20:42:01.086661Z", + "lastModifiedDate": "2019-05-23T20:42:01.086662Z", + "dealerId": "dealer-id-1" + }, + "garageBrand": "RENAULT", + "vehicleDetails": { + "assets": [ + { + "assetType": "PICTURE", + "renditions": [ + { + "resolutionType": "ONE_MYRENAULT_LARGE", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE" + }, + { + "resolutionType": "ONE_MYRENAULT_SMALL", + "url": "https://3dv2.renault.com/ImageFromBookmark?configuration=DLIGM2%2FKITPOU%2FDANGMO%2FITPK4%2FVOLNCH%2FREACTI%2FSSAEBS%2FPRAHL%2FRRCAM%2FX10%2FB10%2FEA3%2FDG%2FCAREG%2FVSTLAR%2FRET03%2FPROJAB%2FRALU16%2FDRAP13%2F3ATRPH%2FTELNJ%2FALEVA%2FVLCUIR%2FRETRCR%2FRETC%2FLVAREL%2FSGSCHA%2FNA418%2FRDIF01%2FTL01A%2FNBT022&databaseId=a864e752-b1b9-405e-9c3e-880073e36cc9&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2" + } + ] + }, + { + "title": "PDF Guide", + "description": "", + "assetType": "PDF", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://cdn.group.renault.com/ren/gb/myr/assets/x102ve/manual.pdf.asset.pdf/1558696740707.pdf" + } + ] + }, + { + "title": "e-guide", + "description": "", + "assetType": "URL", + "assetRole": "GUIDE", + "renditions": [ + { + "url": "https://gb.e-guide.renault.com/eng/Zoe-ph2" + } + ] + }, + { + "title": "All-New ZOE: Welcome to your new car", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "1OGwwmWHB6o" + } + ] + }, + { + "title": "Renault ZOE: All you need to know", + "description": "", + "assetType": "VIDEO", + "assetRole": "CAR", + "renditions": [ + { + "url": "_BVH-Rd6e5I" + } + ] + } + ], + "engineType": "5AQ", + "registrationCountry": { + "code": "FR" + }, + "radioType": { + "group": "425", + "code": "NA418", + "label": " FULL NAV DAB ETH - AUDI" + }, + "tcu": { + "group": "E70", + "code": "AIVCT", + "label": "AVEC BOITIER CONNECT AIVC" + }, + "brand": { + "label": "RENAULT" + }, + "deliveryDate": "2020-01-22", + "engineEnergyType": "ELEC", + "registrationDate": "2020-01-13", + "gearbox": { + "group": "427", + "code": "BVEL", + "label": "BOITE A VARIATEUR ELECTRIQUE" + }, + "model": { + "group": "971", + "code": "X102VE", + "label": "ZOE" + }, + "electrical": true, + "energy": { + "group": "019", + "code": "ELEC", + "label": "ELECTRIQUE" + }, + "navigationAssistanceLevel": { + "group": "408", + "code": "SAN408", + "label": "CRITERE DE CONTEXTE" + }, + "yearsOfMaintenance": 12, + "rlinkStore": false, + "radioCode": "1234", + "registrationNumber": "REG-NUMBER", + "modelSCR": "ZOE", + "easyConnectStore": false, + "engineRatio": "605", + "battery": { + "group": "968", + "code": "BT4AR1", + "label": "BATTERIE BT4AR1" + }, + "vin": "VF1AAAAA555777999", + "retrievedFromDhs": false, + "vcd": "ASCOD0/DLIGM2/SSTINC/KITPOU/SKTPGR/SSCCPC/SDPSEC/FDIU2/SSMAP/SSCALL/FACBA1/DANGMO/SSRCAR/SSCABD/AIVCT/AVGSI/ITPK4/VOLNCH/REACTI/AVOSP1/SWALBO/SSDWGE/1234Y/SSAEBS/PRAHL/RRCAM/STANDA/X10/B10/EA3/MD/ELEC/DG/TEMP/TR4X2/AFURGE/RV/ABS/CAREG/LAC/VSTLAR/CPETIR/RET03/PROJAB/RALU16/CEAVRH/ADAC/AIRBA2/SERIE/DRA/DRAP13/HARM02/3ATRPH/SGAV01/BARRAB/TELNJ/SFBANA/KM/DPRPN/AVREPL/SSDECA/ABLAV/ASRESP/ALEVA/SCACBA/SOP02C/STHPLG/SKTGRV/VLCUIR/RETRCR/TRSEV1/RETC/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/FRA01/APL03/FSTPO/ALOUC5/CMAR3P/SAN408/NA418/BVEL/AUTAUG/SPREST/RDIF01/ISOFIX/EQPEUR/HRGM01/SDPCLV/CHASTD/TL01A/SPRODI/SAN613/AIRBDE/PSMREC/ELC1/SSPTLP/SANCML/SEXTIN/PE2019/PHAS2/SAN913/THABT2/SSTYAD/SSHYB/052KWH/BT4AR1/VEC018/X102VE/NBT022/5AQ", + "firstRegistrationDate": "2020-01-13", + "deliveryCountry": { + "code": "FR", + "label": "FRANCE" + }, + "connectivityTechnology": "RLINK1", + "family": { + "group": "007", + "code": "X10", + "label": "FAMILLE X10" + }, + "version": { + "code": "INT A MD 1L" + } + }, + "status": "ACTIVE", + "createdDate": "2020-08-21T16:48:00.243967Z", + "cancellationReason": {}, + "linkType": "OWNER", + "connectedDriver": { + "role": "MAIN_DRIVER", + "lastModifiedDate": "2020-08-22T09:41:53.477398Z", + "createdDate": "2020-08-22T09:41:53.477398Z" + }, + "vin": "VF1AAAAA555777999", + "lastModifiedDate": "2020-11-29T22:01:21.162572Z", + "brand": "RENAULT", + "startDate": "2020-08-21", + "ownershipStartDate": "2020-01-13", + "ownershipEndDate": "2020-08-21" + } + ], + "accountId": "account-id-1" +} diff --git a/tests/fixtures/whoami.json b/tests/fixtures/whoami.json index c805ef30558..f0630101483 100644 --- a/tests/fixtures/whoami.json +++ b/tests/fixtures/whoami.json @@ -3,6 +3,7 @@ "city": "Gotham", "continent": "Earth", "country": "XX", + "currency": "XXX", "latitude": "12.34567", "longitude": "12.34567", "postal_code": "12345", diff --git a/tests/fixtures/wled/rgbw.json b/tests/fixtures/wled/rgbw.json index d5ba9e8d00c..824612613b1 100644 --- a/tests/fixtures/wled/rgbw.json +++ b/tests/fixtures/wled/rgbw.json @@ -4,7 +4,7 @@ "bri": 140, "transition": 7, "ps": 1, - "pl": -1, + "pl": 3, "nl": { "on": false, "dur": 60, @@ -352,6 +352,46 @@ } ], "n": "Preset 2" + }, + "3": { + "playlist": { + "ps": [ + 1, + 2 + ], + "dur": [ + 30, + 30 + ], + "transition": [ + 7, + 7 + ], + "repeat": 0, + "r": false, + "end": 0 + }, + "n": "Playlist 1" + }, + "4": { + "playlist": { + "ps": [ + 1, + 2 + ], + "dur": [ + 30, + 30 + ], + "transition": [ + 7, + 7 + ], + "repeat": 0, + "r": false, + "end": 0 + }, + "n": "Playlist 2" } } } diff --git a/tests/fixtures/wunderground-error.json b/tests/fixtures/wunderground-error.json deleted file mode 100644 index 264ecbf8cd6..00000000000 --- a/tests/fixtures/wunderground-error.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "response": { - "version": "0.1", - "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", - "features": {}, - "error": { - "type": "keynotfound", - "description": "this key does not exist" - } - } -} diff --git a/tests/fixtures/wunderground-invalid.json b/tests/fixtures/wunderground-invalid.json deleted file mode 100644 index 59661c6694d..00000000000 --- a/tests/fixtures/wunderground-invalid.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "response": { - "version": "0.1", - "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1 - } - }, - "current_observation": { - "image": { - "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", - "title": "Weather Underground", - "link": "http://www.wunderground.com" - } - } -} diff --git a/tests/fixtures/wunderground-valid.json b/tests/fixtures/wunderground-valid.json deleted file mode 100644 index 7ac1081cb4e..00000000000 --- a/tests/fixtures/wunderground-valid.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "response": { - "version": "0.1", - "termsofService": "http://www.wunderground.com/weather/api/d/terms.html", - "features": { - "conditions": 1, - "alerts": 1, - "forecast": 1 - } - }, - "current_observation": { - "image": { - "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png", - "title": "Weather Underground", - "link": "http://www.wunderground.com" - }, - "feelslike_c": "40", - "weather": "Clear", - "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", - "display_location": { - "city": "Holly Springs", - "country": "US", - "full": "Holly Springs, NC" - }, - "observation_location": { - "elevation": "413 ft", - "full": "Twin Lake, Holly Springs, North Carolina" - } - }, - "alerts": [ - { - "type": "FLO", - "description": "Areal Flood Warning", - "date": "9:36 PM CDT on September 22, 2016", - "expires": "10:00 AM CDT on September 23, 2016", - "message": "This is a test alert message" - } - ], - "forecast": { - "txt_forecast": { - "date": "22:35 CEST", - "forecastday": [ - { - "period": 0, - "icon_url": "http://icons.wxug.com/i/c/k/clear.gif", - "title": "Tuesday", - "fcttext": "Mostly Cloudy. Fog overnight.", - "fcttext_metric": "Mostly Cloudy. Fog overnight.", - "pop": "0" - } - ] - }, - "simpleforecast": { - "forecastday": [ - { - "date": { - "pretty": "19:00 CEST 4. Duben 2017" - }, - "period": 1, - "high": { - "fahrenheit": "56", - "celsius": "13" - }, - "low": { - "fahrenheit": "43", - "celsius": "6" - }, - "conditions": "Mo\u017enost de\u0161t\u011b", - "icon_url": "http://icons.wxug.com/i/c/k/chancerain.gif", - "qpf_allday": { - "in": 0.03, - "mm": 1 - }, - "maxwind": { - "mph": 0, - "kph": 0, - "dir": "", - "degrees": 0 - }, - "avewind": { - "mph": 0, - "kph": 0, - "dir": "severn\u00ed", - "degrees": 0 - } - } - ] - } - } -} diff --git a/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json b/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json index 36db78faace..c8d8f878c0b 100644 --- a/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json +++ b/tests/fixtures/zwave_js/aeon_smart_switch_6_state.json @@ -81,7 +81,8 @@ "type": "boolean", "readable": true, "writeable": true, - "label": "Target value" + "label": "Target value", + "valueChangeOptions": ["transitionDuration"] } }, { diff --git a/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json b/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json new file mode 100644 index 00000000000..5616abd6e0f --- /dev/null +++ b/tests/fixtures/zwave_js/aeotec_zw164_siren_state.json @@ -0,0 +1,3756 @@ +{ + "nodeId": 2, + "index": 0, + "installerIcon": 8704, + "userIcon": 8704, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 881, + "productId": 164, + "productType": 259, + "firmwareVersion": "1.3", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/usr/src/app/node_modules/@zwave-js/config/config/devices/0x0371/zw164.json", + "manufacturer": "Aeotec Ltd.", + "manufacturerId": 881, + "label": "ZW164", + "description": "Indoor Siren 6", + "devices": [ + { + "productType": 3, + "productId": 164 + }, + { + "productType": 259, + "productId": 164 + }, + { + "productType": 515, + "productId": 164 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "This product supports Security 2 Command Class. While a Security S2 enabled Controller is needed in order to fully use the security feature. This product can be included and operated in any Z-Wave network with other Z-Wave certified devices from other manufacturers and/or other applications. All non-battery operated nodes within the network will\nact as repeaters regardless of vendor to increase reliability of the network.\n\n1. Set your Z-Wave Controller into its 'Add Device' mode in order to add Chime into your Z-Wave system. Refer to the Controller's manual if you are unsure of how to perform this step.\n\n2. Power on Chime via the provided power adapter; its LED will be breathing white light all the time.\n\n3. Click Chime Action Button once, it will quickly flash white light for 30 seconds until Chime is added into the network. It will become constantly bright white light after being assigned a NodeID.\n\n4. If your Z-Wave Controller supports S2 encryption, enter the first 5 digits of DSK into your Controller's interface if/when requested. The DSK is printed on Chime's housing.\n\n5. If Adding fails, it will slowly flash white light 3 times and then become breathing white light; repeat steps 1 to 4. Contact us for further support if needed.\n\n6. If Adding succeeds, it will quickly flash white light 3 times and then become off. Now, Chime is a part of your Z-Wave home control system. You can configure it and its automations via your Z-Wave system; please refer to your software's user guide for precise instructions.\n\nNote:\nIf Action Button is clicked again during the Learn Mode, the Learn Mode will exit. At the same time, Indicator Light will extinguish immediately, and then become breathing white light", + "exclusion": "1. Set your Z-Wave Controller into its ' Remove Device' mode in order to remove Chime from your Z-Wave system. Refer to the Controller's manual if you are unsure of how to perform this step.\n\n2. Power on Chime via the provided power adapter; its LED will be off.\n\n3. Click Chime Action Button 6 times quickly; it will bright white light, up to 2s.\n\n4. If Removing fails, it will keep off; repeat steps 1 to 3. Contact us for further support if needed.\n\n5. If Removing succeeds, it will quickly flash white light 3 times and then become breathing white light. Now, Chime is removed from Z-Wave network successfully", + "reset": "If the primary controller is missing or inoperable, you may need to reset the device to factory settings.\n\nMake sure the Chime is powered. To complete the reset process manually, press and hold the Action Button for at least 20s. The LED indicator will quickly flash white light 3 times and then become breathing white light, which indicates the reset operation is successful. Otherwise, please try again. Contact us for further support if needed.\n\nNote:\n1. This procedure should only be used when the primary controller is missing or inoperable.\n2. Factory Reset Chime will:\n(a) Remove Chime from Z-Wave network;\n(b) Delete the Association setting;\n(c) Restore the configuration settings to the default.(Except configuration parameter 51/52/53/54)", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/3301/Indoor%20Siren%206%20product%20manual.pdf" + }, + "isEmbedded": true + }, + "label": "ZW164", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": true, + "individualEndpointCount": 8, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 1, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 2, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 3, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 4, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 5, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 6, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 7, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + }, + { + "nodeId": 2, + "index": 8, + "installerIcon": 8704, + "userIcon": 8704, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "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 + }, + "value": 881 + }, + { + "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 + }, + "value": 259 + }, + { + "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 + }, + "value": 164 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "5.3" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 164 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Group 2 Basic Set Command (Browse)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 2 Basic Set Command (Browse)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Group 3 Basic Set Command (Tampering)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 3 Basic Set Command (Tampering)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "Group 4 Basic Set Command (Doorbell 1)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 4 Basic Set Command (Doorbell 1)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Group 5 Basic Set Command (Doorbell 2)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 5 Basic Set Command (Doorbell 2)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Group 6 Basic Set Command (Doorbell 3)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 6 Basic Set Command (Doorbell 3)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyName": "Group 7 Basic Set Command (Environment)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 7 Basic Set Command (Environment)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyName": "Group 8 Basic Set Command (Security)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 8 Basic Set Command (Security)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "Group 9 Basic Set Command (Emergency)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Group 9 Basic Set Command (Emergency)", + "default": 3, + "min": 0, + "max": 4, + "states": { + "0": "Disable", + "1": "Start playing -> On; Stop playing -> None", + "2": "Start playing -> Off; Stop playing -> None", + "3": "Start playing -> On; Stop playing -> Off", + "4": "Start playing -> Off; Stop playing -> On" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyName": "Pairing Mode Status", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Pairing Mode Status", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Not pairing", + "1": "Pairing Button No. 1", + "2": "Pairing Button No. 1", + "4": "Pairing Button No. 1" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Browse)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Browse)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 16711680, + "propertyName": "Tone Play Mode (Browse)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Mode (Browse)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Single playback", + "1": "Single loop playback", + "2": "Loop playback tones", + "3": "Random playback tones", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Tamper)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Tamper)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Tamper)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Tamper)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Tamper)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Tamper)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 255, + "propertyName": "Tone Play Count (Tamper)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Tamper)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Doorbell 1)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Doorbell 1)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Doorbell 1)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Doorbell 1)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Doorbell 1)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Doorbell 1)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 255, + "propertyName": "Tone Play Count (Doorbell 1)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Doorbell 1)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Doorbell 2)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Doorbell 2)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Doorbell 2)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Doorbell 2)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Doorbell 2)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Doorbell 2)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyKey": 255, + "propertyName": "Tone Play Count (Doorbell 2)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Doorbell 2)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Doorbell 3)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Doorbell 3)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Doorbell 3)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Doorbell 3)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Doorbell 3)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Doorbell 3)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyKey": 255, + "propertyName": "Tone Play Count (Doorbell 3)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Doorbell 3)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Environment)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Environment)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Environment)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Environment)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Environment)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Environment)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyKey": 255, + "propertyName": "Tone Play Count (Environment)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Environment)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Security)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Security)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Security)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Security)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Security)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Security)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyKey": 255, + "propertyName": "Tone Play Count (Security)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Security)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyKey": 4278190080, + "propertyName": "Light Effect Index (Emergency)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect Index (Emergency)", + "default": 1, + "min": 1, + "max": 127, + "states": { + "1": "Light effect #1", + "2": "Light effect #2", + "4": "Light effect #3", + "8": "Light effect #4", + "16": "Light effect #5", + "32": "Light effect #6", + "64": "Light effect #7", + "127": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyKey": 16711680, + "propertyName": "Tone Duration (Emergency)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Duration (Emergency)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Original tone length", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyKey": 65280, + "propertyName": "Interval Between Tones (Emergency)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Interval Between Tones (Emergency)", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "No interval", + "255": "Last configuration value" + }, + "unit": "seconds", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyKey": 255, + "propertyName": "Tone Play Count (Emergency)", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone Play Count (Emergency)", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Unlimited", + "255": "Last configuration value" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 1: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 1: Dim On Duration", + "default": 75, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 75 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 1: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 1: Dim Off Duration", + "default": 25, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 25 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyKey": 65280, + "propertyName": "Light Effect No. 1: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 1: LED Indicator On Duration", + "default": 20, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyKey": 255, + "propertyName": "Light Effect No. 1: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 1: LED Indicator Off Duration", + "default": 3, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 2: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 2: Dim On Duration", + "default": 50, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 2: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 2: Dim Off Duration", + "default": 50, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyKey": 65280, + "propertyName": "Light Effect No. 2: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 2: LED Indicator On Duration", + "default": 0, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyKey": 255, + "propertyName": "Light Effect No. 2: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 2: LED Indicator Off Duration", + "default": 3, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 3: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 3: Dim On Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 3: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 3: Dim Off Duration", + "default": 33, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 33 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 65280, + "propertyName": "Light Effect No. 3: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 3: LED Indicator On Duration", + "default": 1, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 255, + "propertyName": "Light Effect No. 3: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 3: LED Indicator Off Duration", + "default": 3, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 4: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 4: Dim On Duration", + "default": 33, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 33 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 4: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 4: Dim Off Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyKey": 65280, + "propertyName": "Light Effect No. 4: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 4: LED Indicator On Duration", + "default": 0, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyKey": 255, + "propertyName": "Light Effect No. 4: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 4: LED Indicator Off Duration", + "default": 3, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 5: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 5: Dim On Duration", + "default": 33, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 5: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 5: Dim Off Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyKey": 65280, + "propertyName": "Light Effect No. 5: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 5: LED Indicator On Duration", + "default": 0, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyKey": 255, + "propertyName": "Light Effect No. 5: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 5: LED Indicator Off Duration", + "default": 3, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 6: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 6: Dim On Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 6: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 6: Dim Off Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyKey": 65280, + "propertyName": "Light Effect No. 6: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 6: LED Indicator On Duration", + "default": 10, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyKey": 255, + "propertyName": "Light Effect No. 6: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 6: LED Indicator Off Duration", + "default": 0, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 4278190080, + "propertyName": "Light Effect No. 7: Dim On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 7: Dim On Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 33 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 16711680, + "propertyName": "Light Effect No. 7: Dim Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 7: Dim Off Duration", + "default": 0, + "min": 0, + "max": 127, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 65280, + "propertyName": "Light Effect No. 7: LED Indicator On Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 7: LED Indicator On Duration", + "default": 0, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 255, + "propertyName": "Light Effect No. 7: LED Indicator Off Duration", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Light Effect No. 7: LED Indicator Off Duration", + "default": 1, + "min": 0, + "max": 255, + "unit": "ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 51, + "propertyKey": 1, + "propertyName": "Status: Button 1", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Status: Button 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Not paired", + "1": "Paired" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 51, + "propertyKey": 2, + "propertyName": "Status: Button 2", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Status: Button 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Not paired", + "1": "Paired" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 51, + "propertyKey": 4, + "propertyName": "Status: Button 3", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Status: Button 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Not paired", + "1": "Paired" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 52, + "propertyKey": 4294901760, + "propertyName": "Button 1: Battery Voltage", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Button 1: Battery Voltage", + "default": 0, + "min": 0, + "max": 32767, + "states": { + "0": "Not paired" + }, + "unit": "mV", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 52, + "propertyKey": 65535, + "propertyName": "Button 1: Software Version", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1: Software Version", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Not paired" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 53, + "propertyKey": 4294901760, + "propertyName": "Button 2: Battery Voltage", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Button 2: Battery Voltage", + "default": 0, + "min": 0, + "max": 32767, + "states": { + "0": "Not paired" + }, + "unit": "mV", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 53, + "propertyKey": 65535, + "propertyName": "Button 2: Software Version", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2: Software Version", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Not paired" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 54, + "propertyKey": 4294901760, + "propertyName": "Button 3: Battery Voltage", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Button 3: Battery Voltage", + "default": 0, + "min": 0, + "max": 32767, + "states": { + "0": "Not paired" + }, + "unit": "mV", + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 54, + "propertyKey": 65535, + "propertyName": "Button 3: Software Version", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3: Software Version", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Not paired" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 48, + "propertyName": "Button Unpairing", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Button Unpairing", + "default": 0, + "min": 0, + "max": 7, + "states": { + "0": "Normal Operation", + "1": "Unpair Button No. 1", + "2": "Unpair Button No. 2", + "4": "Unpair Button No. 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 49, + "propertyName": "Button Pairing", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Button Pairing", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Stop pairing", + "1": "Pair Button No. 1", + "2": "Pair Button No. 2", + "4": "Pair Button No. 3" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 96, + "propertyName": "Stop Playing Tone on Action Button", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Stop Playing Tone on Action Button", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 255, + "propertyName": "Reset to Factory Default Setting", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Reset to Factory Default Setting", + "default": 0, + "min": 0, + "max": 1431655765, + "states": { + "1": "Resets all configuration parameters to default setting", + "1431655765": "Reset the product to factory default setting and exclude from Z-Wave network" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 96, + "commandClassName": "Multi Channel", + "property": "endpointIndizes", + "propertyName": "endpointIndizes", + "ccVersion": 4, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + }, + "value": [1, 2, 3, 4, 5, 6, 7, 8] + }, + { + "endpoint": 1, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 1, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 6 + }, + { + "endpoint": 1, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Tone ID", + "min": 0, + "max": 255, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 1, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 17 + }, + { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 2, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 3, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 3, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 1 + }, + { + "endpoint": 3, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 3, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 3, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Battery maintenance status", + "propertyName": "Power Management", + "propertyKeyName": "Battery maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery maintenance status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "10": "Replace battery soon" + } + } + }, + { + "endpoint": 3, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 4, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 4, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 3 + }, + { + "endpoint": 4, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 4, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 4, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Battery maintenance status", + "propertyName": "Power Management", + "propertyKeyName": "Battery maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery maintenance status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "10": "Replace battery soon" + } + } + }, + { + "endpoint": 4, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 5, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 5, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 5 + }, + { + "endpoint": 5, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 5, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 5, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Battery maintenance status", + "propertyName": "Power Management", + "propertyKeyName": "Battery maintenance status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery maintenance status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "10": "Replace battery soon" + } + } + }, + { + "endpoint": 5, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 6, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 6, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 9 + }, + { + "endpoint": 6, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 6, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 6, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 7, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 7, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 18 + }, + { + "endpoint": 7, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 7, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 7, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + }, + { + "endpoint": 8, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultVolume", + "propertyName": "defaultVolume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default volume", + "min": 0, + "max": 100, + "unit": "%" + }, + "value": 100 + }, + { + "endpoint": 8, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "defaultToneId", + "propertyName": "defaultToneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Default tone ID", + "min": 0, + "max": 254 + }, + "value": 11 + }, + { + "endpoint": 8, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "toneId", + "propertyName": "toneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Play Tone", + "min": 0, + "max": 30, + "states": { + "0": "off", + "1": "01DING~1 (5 sec)", + "2": "02DING~1 (9 sec)", + "3": "03TRAD~1 (11 sec)", + "4": "04ELEC~1 (2 sec)", + "5": "05WEST~1 (13 sec)", + "6": "06CHIM~1 (7 sec)", + "7": "07CUCK~1 (31 sec)", + "8": "08TRAD~1 (6 sec)", + "9": "09SMOK~1 (11 sec)", + "10": "10SMOK~1 (6 sec)", + "11": "11FIRE~1 (35 sec)", + "12": "12COSE~1 (5 sec)", + "13": "13KLAX~1 (38 sec)", + "14": "14DEEP~1 (41 sec)", + "15": "15WARN~1 (37 sec)", + "16": "16TORN~1 (46 sec)", + "17": "17ALAR~1 (35 sec)", + "18": "18DEEP~1 (62 sec)", + "19": "19ALAR~1 (15 sec)", + "20": "20ALAR~1 (7 sec)", + "21": "21DIGI~1 (8 sec)", + "22": "22ALER~1 (64 sec)", + "23": "23SHIP~1 (4 sec)", + "24": "24CLOC~1 (10 sec)", + "25": "25CHRI~1 (4 sec)", + "26": "26GONG~1 (12 sec)", + "27": "27SING~1 (1 sec)", + "28": "28TONA~1 (5 sec)", + "29": "29UPWA~1 (2 sec)", + "30": "30DOOR~1 (27 sec)", + "255": "default" + }, + "valueChangeOptions": ["volume"] + } + }, + { + "endpoint": 8, + "commandClass": 121, + "commandClassName": "Sound Switch", + "property": "volume", + "propertyName": "volume", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Volume", + "min": 0, + "max": 100, + "states": { + "0": "default" + }, + "unit": "%" + } + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Siren", + "propertyKey": "Siren status", + "propertyName": "Siren", + "propertyKeyName": "Siren status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Siren status", + "ccSpecific": { + "notificationType": 14 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Siren active" + } + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 3, + "label": "AV Control Point" + }, + "specific": { + "key": 1, + "label": "Sound Switch" + }, + "mandatorySupportedCCs": [ + 32, 133, 89, 128, 121, 114, 115, 159, 108, 85, 134, 94 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 121, + "name": "Sound Switch", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0371:0x0103:0x00a4:1.3" +} diff --git a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json index 64bfecfb20b..dfa72af6aa4 100644 --- a/tests/fixtures/zwave_js/bulb_6_multi_color_state.json +++ b/tests/fixtures/zwave_js/bulb_6_multi_color_state.json @@ -75,10 +75,13 @@ "type": "number", "readable": true, "writeable": true, + "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, - "max": 99, - "label": "Target value" - } + "max": 99 + } }, { "commandClassName": "Multilevel Switch", @@ -264,7 +267,8 @@ "min": 0, "max": 255, "label": "Target value (Warm White)", - "description": "The target value of the Warm White color." + "description": "The target value of the Warm White color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -282,7 +286,8 @@ "min": 0, "max": 255, "label": "Target value (Cold White)", - "description": "The target value of the Cold White color." + "description": "The target value of the Cold White color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -300,7 +305,8 @@ "min": 0, "max": 255, "label": "Target value (Red)", - "description": "The target value of the Red color." + "description": "The target value of the Red color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -318,7 +324,8 @@ "min": 0, "max": 255, "label": "Target value (Green)", - "description": "The target value of the Green color." + "description": "The target value of the Green color.", + "valueChangeOptions": ["transitionDuration"] } }, { @@ -336,9 +343,27 @@ "min": 0, "max": 255, "label": "Target value (Blue)", - "description": "The target value of the Blue color." + "description": "The target value of the Blue color.", + "valueChangeOptions": ["transitionDuration"] } }, + { + "endpoint": 0, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target Color", + "valueChangeOptions": [ + "transitionDuration" + ] + } + }, { "commandClassName": "Configuration", "commandClass": 112, diff --git a/tests/fixtures/zwave_js/cover_aeotec_nano_shutter_state.json b/tests/fixtures/zwave_js/cover_aeotec_nano_shutter_state.json new file mode 100644 index 00000000000..b5373f38ec4 --- /dev/null +++ b/tests/fixtures/zwave_js/cover_aeotec_nano_shutter_state.json @@ -0,0 +1,498 @@ +{ + "nodeId": 3, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 881, + "productId": 141, + "productType": 3, + "firmwareVersion": "3.1", + "zwavePlusVersion": 1, + "deviceConfig": { + "filename": "/opt/node_modules/@zwave-js/config/config/devices/0x0371/zw141.json", + "manufacturer": "Aeotec Ltd.", + "manufacturerId": 881, + "label": "ZW141", + "description": "Nano Shutter V.3", + "devices": [ + { + "productType": 3, + "productId": 141 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "3.5.2 Classic inclusion Learn Mode\n1. Set your Z-Wave Controller into its 'Add Device' mode in order to add the product into your Z-Wave system. Refer to the Controller's manual if you are unsure of how to perform this step.\n2. Make sure the product is powered. If not, plug it into a wall socket and power on; its LED will be breathing blue light all the time.\n3. Click Action Button once, it will quickly flash blue light for 30 seconds until it is added into the network. It will become constantly bright yellow light after being assigned a NodeID.\n4. If your Z-Wave Controller supports S2 encryption, enter the first 5 digits of DSK into your Controller's interface if/when requested. The DSK is printed on its housing.\n5. If Adding fails, it will bright red light for 2s and then become breathing blue light; repeat steps 1 to 4. Contact us for further support if needed.\n6. If Adding succeeds, it will bright blue light for 2s and then turn to Load Indicator Mode. Now, this product is a part of your Z-Wave home control system. You can configure it and its automations via your Z-Wave system; please refer to your software's user guide for precise instructions", + "exclusion": "3.6 How to Remove the device from Z-Wave network\n1. Set your Z-Wave Controller into its 'Remove Device' mode in order to remove the product from your Z-Wave system.Refer to the Controller's manual if you are unsure of how to perform this step.\n2. Click Action Button/S1/S2(external switch need to be identified first) 6 times will enter exclusion mode.\n3. If Removing fails, it will bright red light for 2s then turn back to Regular Light Mode, repeat steps 1-2. Contact us for further support if needed.\n4. If Removing succeeds, it will become breathing blue light. Now, it is removed from Z-Wave network successfully", + "reset": "3.7 How to Factory Reset\nManually, press and hold the Action Button for at least 20s and then release. The LED indicator will become breathing blue light, which indicates the reset operation is successful. Otherwise, please try again. Contact us for further support if needed.\nNote:\n1. This procedure should only be used when the primary controller is missing or inoperable.\n2. Factory Reset will:\na) Remove the product from Z-Wave network;\nb) Delete the Association setting;", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3693/Nano%20Shutter%20-%20Product%20Manual.pdf" + }, + "isEmbedded": true + }, + "label": "ZW141", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 3, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + } + } + ], + "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 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + }, + "value": { + "value": 0, + "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 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "On", + "propertyName": "On", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (On)", + "ccSpecific": { + "switchType": 1 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Off", + "propertyName": "Off", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Off)", + "ccSpecific": { + "switchType": 1 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": [ + "transitionDuration" + ], + "min": 1, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Dimming duration" + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate" + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x" + } + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x" + } + } + }, + { + "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 + }, + "value": 881 + }, + { + "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 + }, + "value": 3 + }, + { + "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 + }, + "value": 141 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "6.4" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": [ + "3.1" + ] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [ + 40000, + 100000 + ], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [ + 32, + 38, + 37, + 114, + 134 + ], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": true + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0371:0x0003:0x008d:3.1" +} diff --git a/tests/fixtures/zwave_js/ge_in_wall_dimmer_switch_state.json b/tests/fixtures/zwave_js/ge_in_wall_dimmer_switch_state.json new file mode 100644 index 00000000000..58d3f0d06ec --- /dev/null +++ b/tests/fixtures/zwave_js/ge_in_wall_dimmer_switch_state.json @@ -0,0 +1,642 @@ +{ + "nodeId": 2, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 99, + "productId": 12344, + "productType": 18756, + "firmwareVersion": "5.26", + "zwavePlusVersion": 1, + "name": "LivingRoomLight", + "location": "LivingRoom", + "deviceConfig": { + "filename": "/opt/node_modules/@zwave-js/config/config/devices/0x0063/ge_14294_zw3005.json", + "manufacturer": "GE/Jasco", + "manufacturerId": 99, + "label": "14294 / ZW3005", + "description": "In-Wall Dimmer Switch", + "devices": [ + { + "productType": 18756, + "productId": 12344 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "valueIdRegex": {}, + "treatBasicSetAsEvent": true + }, + "isEmbedded": true + }, + "label": "14294 / ZW3005", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "installerIcon": 1536, + "userIcon": 1536, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + } + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 99 + } + }, + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 99 + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Transition duration" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 35 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + } + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "min": 1, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 0, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Dimming duration" + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Night Light", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Defines the behavior of the blue LED. Default is on when switch is off.", + "label": "Night Light", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED on when switch is OFF", + "1": "LED on when switch is ON", + "2": "LED always off" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Invert Switch", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Invert the ON/OFF Switch State.", + "label": "Invert Switch", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "No", + "1": "Yes" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Dim Rate Steps (Z-Wave Command)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of steps or levels", + "label": "Dim Rate Steps (Z-Wave Command)", + "default": 1, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Dim Rate Timing (Z-Wave)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Timing of steps or levels", + "label": "Dim Rate Timing (Z-Wave)", + "default": 3, + "min": 1, + "max": 255, + "unit": "10ms", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Dim Rate Steps (Manual)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of steps or levels", + "label": "Dim Rate Steps (Manual)", + "default": 1, + "min": 1, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Dim Rate Timing (Manual)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Timing of steps", + "label": "Dim Rate Timing (Manual)", + "default": 3, + "min": 1, + "max": 255, + "unit": "10ms", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Dim Rate Steps (All-On/All-Off)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of steps or levels", + "label": "Dim Rate Steps (All-On/All-Off)", + "default": 1, + "min": 1, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Dim Rate Timing (All-On/All-Off)", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Timing of steps or levels", + "label": "Dim Rate Timing (All-On/All-Off)", + "default": 3, + "min": 1, + "max": 255, + "unit": "10ms", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "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 + }, + "value": 99 + }, + { + "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 + }, + "value": 18756 + }, + { + "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 + }, + "value": 12344 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Library type" + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "4.34" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["5.26"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + } + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 3, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 44, + "name": "Scene Actuator Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": false + } + ], + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3038:5.26" +} diff --git a/tests/fixtures/zwave_js/light_color_null_values_state.json b/tests/fixtures/zwave_js/light_color_null_values_state.json index 46bc9f29b06..213b873f85c 100644 --- a/tests/fixtures/zwave_js/light_color_null_values_state.json +++ b/tests/fixtures/zwave_js/light_color_null_values_state.json @@ -1,5 +1,5 @@ { - "nodeId": 39, + "nodeId": 40, "index": 0, "installerIcon": 6912, "userIcon": 6912, @@ -99,6 +99,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 } @@ -294,7 +297,8 @@ "description": "The target value of the Red color.", "label": "Target value (Red)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -313,7 +317,8 @@ "description": "The target value of the Green color.", "label": "Target value (Green)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -332,7 +337,8 @@ "description": "The target value of the Blue color.", "label": "Target value (Blue)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -346,7 +352,8 @@ "type": "any", "readable": true, "writeable": true, - "label": "Target Color" + "label": "Target Color", + "valueChangeOptions": ["transitionDuration"] } }, { diff --git a/tests/fixtures/zwave_js/lock_schlage_be469_state.json b/tests/fixtures/zwave_js/lock_schlage_be469_state.json index be1ddb9c3f0..f85a8e6b005 100644 --- a/tests/fixtures/zwave_js/lock_schlage_be469_state.json +++ b/tests/fixtures/zwave_js/lock_schlage_be469_state.json @@ -50,7 +50,68 @@ "index": 0 } ], - "commandClasses": [], + "commandClasses": [ + { + "id": 98, + "name": "Door Lock", + "version": 1, + "isSecure": true + }, + { + "id": 99, + "name": "User Code", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 1, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 1, + "isSecure": false + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ], "values": [ { "commandClassName": "Door Lock", diff --git a/tests/fixtures/zwave_js/zen_31_state.json b/tests/fixtures/zwave_js/zen_31_state.json index d322279fbfa..7407607e086 100644 --- a/tests/fixtures/zwave_js/zen_31_state.json +++ b/tests/fixtures/zwave_js/zen_31_state.json @@ -1430,6 +1430,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -1642,7 +1645,8 @@ "type": "any", "readable": true, "writeable": true, - "label": "Target Color" + "label": "Target Color", + "valueChangeOptions": ["transitionDuration"] }, "value": { "warmWhite": 141, @@ -1667,7 +1671,8 @@ "description": "The target value of the Warm White color.", "label": "Target value (Warm White)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -1686,7 +1691,8 @@ "description": "The target value of the Red color.", "label": "Target value (Red)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -1705,7 +1711,8 @@ "description": "The target value of the Green color.", "label": "Target value (Green)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -1724,7 +1731,8 @@ "description": "The target value of the Blue color.", "label": "Target value (Blue)", "min": 0, - "max": 255 + "max": 255, + "valueChangeOptions": ["transitionDuration"] } }, { @@ -1982,6 +1990,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2100,6 +2111,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2218,6 +2232,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, @@ -2336,6 +2353,9 @@ "readable": true, "writeable": true, "label": "Target value", + "valueChangeOptions": [ + "transitionDuration" + ], "min": 0, "max": 99 }, diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 02303825bbd..c5e9f5880c4 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1085,3 +1085,18 @@ def test_whitespace(): for value in (" ", " "): assert schema(value) + + +def test_currency(): + """Test currency validator.""" + schema = vol.Schema(cv.currency) + + for value in ( + None, + "BTC", + ): + with pytest.raises(vol.MultipleInvalid): + schema(value) + + for value in ("EUR", "USD"): + assert schema(value) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 037e1aec8c2..557647c5c7f 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1253,3 +1253,45 @@ async def test_disable_config_entry_disables_devices(hass, registry): entry2 = registry.async_get(entry2.id) assert entry2.disabled assert entry2.disabled_by == device_registry.DISABLED_USER + + +async def test_only_disable_device_if_all_config_entries_are_disabled(hass, registry): + """Test that we only disable device if all related config entries are disabled.""" + config_entry1 = MockConfigEntry(domain="light") + config_entry1.add_to_hass(hass) + config_entry2 = MockConfigEntry(domain="light") + config_entry2.add_to_hass(hass) + + registry.async_get_or_create( + config_entry_id=config_entry1.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry1 = registry.async_get_or_create( + config_entry_id=config_entry2.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert len(entry1.config_entries) == 2 + assert not entry1.disabled + + await hass.config_entries.async_set_disabled_by( + config_entry1.entry_id, config_entries.DISABLED_USER + ) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert not entry1.disabled + + await hass.config_entries.async_set_disabled_by( + config_entry2.entry_id, config_entries.DISABLED_USER + ) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert entry1.disabled + assert entry1.disabled_by == device_registry.DISABLED_CONFIG_ENTRY + + await hass.config_entries.async_set_disabled_by(config_entry1.entry_id, None) + await hass.async_block_till_done() + + entry1 = registry.async_get(entry1.id) + assert not entry1.disabled diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py index e27114c1a13..fd9d488596f 100644 --- a/tests/helpers/test_system_info.py +++ b/tests/helpers/test_system_info.py @@ -1,5 +1,6 @@ """Tests for the system info helper.""" import json +from unittest.mock import patch from homeassistant.const import __version__ as current_version @@ -9,4 +10,20 @@ async def test_get_system_info(hass): info = await hass.helpers.system_info.async_get_system_info() assert isinstance(info, dict) assert info["version"] == current_version + assert info["user"] is not None assert json.dumps(info) is not None + + +async def test_container_installationtype(hass): + """Test container installation type.""" + with patch("platform.system", return_value="Linux"), patch( + "os.path.isfile", return_value=True + ): + info = await hass.helpers.system_info.async_get_system_info() + assert info["installation_type"] == "Home Assistant Container" + + with patch("platform.system", return_value="Linux"), patch( + "os.path.isfile", return_value=True + ), patch("homeassistant.helpers.system_info.getuser", return_value="user"): + info = await hass.helpers.system_info.async_get_system_info() + assert info["installation_type"] == "Unknown" diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 2547537bff9..d6fe2b6dbaf 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1585,6 +1585,151 @@ async def test_device_entities(hass): assert info.rate_limit is None +async def test_device_id(hass): + """Test device_id function.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model="test", + ) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id + ) + entity_entry_no_device = entity_registry.async_get_or_create( + "sensor", "test", "test_no_device", suggested_object_id="test" + ) + + info = render_to_info(hass, "{{ 'sensor.fail' | device_id }}") + assert_result_info(info, None) + assert info.rate_limit is None + + with pytest.raises(TemplateError): + info = render_to_info(hass, "{{ 56 | device_id }}") + assert_result_info(info, None) + + with pytest.raises(TemplateError): + info = render_to_info(hass, "{{ 'not_a_real_entity_id' | device_id }}") + assert_result_info(info, None) + + info = render_to_info( + hass, f"{{{{ device_id('{entity_entry_no_device.entity_id}') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ device_id('{entity_entry.entity_id}') }}}}") + assert_result_info(info, device_entry.id) + assert info.rate_limit is None + + +async def test_device_attr(hass): + """Test device_attr and is_device_attr functions.""" + config_entry = MockConfigEntry(domain="light") + device_registry = mock_device_registry(hass) + entity_registry = mock_registry(hass) + + # Test non existing device ids (device_attr) + info = render_to_info(hass, "{{ device_attr('abc123', 'id') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + 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) + info = render_to_info(hass, "{{ is_device_attr('abc123', 'id', 'test') }}") + assert_result_info(info, False) + assert info.rate_limit is None + + 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) + info = render_to_info(hass, "{{ device_attr('entity.test', 'id') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existing entity id (is_device_attr) + info = render_to_info(hass, "{{ is_device_attr('entity.test', 'id', 'test') }}") + assert_result_info(info, False) + assert info.rate_limit is None + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + model="test", + ) + entity_entry = entity_registry.async_get_or_create( + "sensor", "test", "test", suggested_object_id="test", device_id=device_entry.id + ) + + # Test non existent device attribute (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{device_entry.id}', 'invalid_attr') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + # Test non existent device attribute (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'invalid_attr', 'test') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test None device attribute (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{device_entry.id}', 'manufacturer') }}}}" + ) + assert_result_info(info, None) + assert info.rate_limit is None + + # Test None device attribute mismatch (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', 'test') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test None device attribute match (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'manufacturer', None) }}}}" + ) + assert_result_info(info, True) + assert info.rate_limit is None + + # Test valid device attribute match (device_attr) + info = render_to_info(hass, f"{{{{ device_attr('{device_entry.id}', 'model') }}}}") + assert_result_info(info, "test") + assert info.rate_limit is None + + # Test valid device attribute match (device_attr) + info = render_to_info( + hass, f"{{{{ device_attr('{entity_entry.entity_id}', 'model') }}}}" + ) + assert_result_info(info, "test") + assert info.rate_limit is None + + # Test valid device attribute mismatch (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'fail') }}}}" + ) + assert_result_info(info, False) + assert info.rate_limit is None + + # Test valid device attribute match (is_device_attr) + info = render_to_info( + hass, f"{{{{ is_device_attr('{device_entry.id}', 'model', 'test') }}}}" + ) + assert_result_info(info, True) + assert info.rate_limit is None + + def test_closest_function_to_coord(hass): """Test closest function to coord.""" hass.states.async_set( diff --git a/tests/test_config.py b/tests/test_config.py index 87496c566e3..c1eb1ab7540 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -193,6 +193,7 @@ def test_core_config_schema(): {"longitude": -181}, {"external_url": "not an url"}, {"internal_url": "not an url"}, + {"currency", 100}, {"customize": "bla"}, {"customize": {"light.sensor": 100}}, {"customize": {"entity_id": []}}, @@ -208,6 +209,7 @@ def test_core_config_schema(): "external_url": "https://www.example.com", "internal_url": "http://example.local", CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, + "currency": "USD", "customize": {"sensor.temperature": {"hidden": True}}, } ) @@ -360,6 +362,7 @@ async def test_loading_configuration_from_storage(hass, hass_storage): "unit_system": "metric", "external_url": "https://www.example.com", "internal_url": "http://example.local", + "currency": "EUR", }, "key": "core.config", "version": 1, @@ -376,6 +379,7 @@ async def test_loading_configuration_from_storage(hass, hass_storage): assert hass.config.time_zone == "Europe/Copenhagen" assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" + assert hass.config.currency == "EUR" assert len(hass.config.allowlist_external_dirs) == 3 assert "/etc" in hass.config.allowlist_external_dirs assert hass.config.config_source == SOURCE_STORAGE @@ -423,6 +427,7 @@ async def test_updating_configuration(hass, hass_storage): "unit_system": "metric", "external_url": "https://www.example.com", "internal_url": "http://example.local", + "currency": "BTC", }, "key": "core.config", "version": 1, @@ -431,12 +436,14 @@ async def test_updating_configuration(hass, hass_storage): await config_util.async_process_ha_core_config( hass, {"allowlist_external_dirs": "/etc"} ) - await hass.config.async_update(latitude=50) + await hass.config.async_update(latitude=50, currency="USD") new_core_data = copy.deepcopy(core_data) new_core_data["data"]["latitude"] = 50 + new_core_data["data"]["currency"] = "USD" assert hass_storage["core.config"] == new_core_data assert hass.config.latitude == 50 + assert hass.config.currency == "USD" async def test_override_stored_configuration(hass, hass_storage): @@ -484,6 +491,7 @@ async def test_loading_configuration(hass): "internal_url": "http://example.local", "media_dirs": {"mymedia": "/usr"}, "legacy_templates": True, + "currency": "EUR", }, ) @@ -501,6 +509,7 @@ async def test_loading_configuration(hass): assert hass.config.media_dirs == {"mymedia": "/usr"} assert hass.config.config_source == config_util.SOURCE_YAML assert hass.config.legacy_templates is True + assert hass.config.currency == "EUR" async def test_loading_configuration_temperature_unit(hass): @@ -528,6 +537,7 @@ async def test_loading_configuration_temperature_unit(hass): assert hass.config.external_url == "https://www.example.com" assert hass.config.internal_url == "http://example.local" assert hass.config.config_source == config_util.SOURCE_YAML + assert hass.config.currency == "EUR" async def test_loading_configuration_default_media_dirs_docker(hass): diff --git a/tests/test_core.py b/tests/test_core.py index 39c5b310537..77ec07e6a63 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -315,9 +315,9 @@ def test_event_eq(): now = dt_util.utcnow() data = {"some": "attr"} context = ha.Context() - event1, event2 = [ + event1, event2 = ( ha.Event("some_type", data, time_fired=now, context=context) for _ in range(2) - ] + ) assert event1 == event2 @@ -912,6 +912,7 @@ def test_config_defaults(): assert config.media_dirs == {} assert config.safe_mode is False assert config.legacy_templates is False + assert config.currency == "EUR" def test_config_path_with_file(): @@ -952,6 +953,7 @@ def test_config_as_dict(): "state": "RUNNING", "external_url": None, "internal_url": None, + "currency": "EUR", } assert expected == config.as_dict() diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 26f3603910d..82ce10872bf 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -38,6 +38,7 @@ async def test_requirement_installed_in_venv(hass): assert mock_install.call_args == call( "package==0.0.1", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), + timeout=60, no_cache_dir=False, ) @@ -59,6 +60,7 @@ async def test_requirement_installed_in_deps(hass): "package==0.0.1", target=hass.config.path("deps"), constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), + timeout=60, no_cache_dir=False, ) @@ -304,6 +306,7 @@ async def test_install_with_wheels_index(hass): "hello==1.0.0", find_links="https://wheels.hass.io/test", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), + timeout=60, no_cache_dir=True, ) @@ -327,6 +330,7 @@ async def test_install_on_docker(hass): assert mock_inst.call_args == call( "hello==1.0.0", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), + timeout=60, no_cache_dir=True, ) diff --git a/tests/testing_config/custom_components/test/alarm_control_panel.py b/tests/testing_config/custom_components/test/alarm_control_panel.py index 864c99ec5df..f38bf48fc94 100644 --- a/tests/testing_config/custom_components/test/alarm_control_panel.py +++ b/tests/testing_config/custom_components/test/alarm_control_panel.py @@ -8,12 +8,14 @@ from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, SUPPORT_ALARM_ARM_HOME, SUPPORT_ALARM_ARM_NIGHT, + SUPPORT_ALARM_ARM_VACATION, SUPPORT_ALARM_TRIGGER, ) from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_ARMED_VACATION, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) @@ -79,6 +81,7 @@ class MockAlarm(MockEntity, AlarmControlPanelEntity): | SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_NIGHT | SUPPORT_ALARM_TRIGGER + | SUPPORT_ALARM_ARM_VACATION ) def alarm_arm_away(self, code=None): @@ -96,6 +99,11 @@ class MockAlarm(MockEntity, AlarmControlPanelEntity): self._state = STATE_ALARM_ARMED_NIGHT self.schedule_update_ha_state() + def alarm_arm_vacation(self, code=None): + """Send arm night command.""" + self._state = STATE_ALARM_ARMED_VACATION + self.schedule_update_ha_state() + def alarm_disarm(self, code=None): """Send disarm command.""" if code == "1234": diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 19413c57aaa..cae47835cd8 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -165,7 +165,7 @@ async def test_gather_with_concurrency(): return runs results = await hasync.gather_with_concurrency( - 2, *[_increment_runs_if_in_time() for i in range(4)] + 2, *(_increment_runs_if_in_time() for i in range(4)) ) assert results == [2, 2, -1, -1] diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 34e95013b26..7a4f13cb767 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -74,6 +74,7 @@ def test_slugify(): assert util.slugify("$$$") == "unknown" assert util.slugify("$something") == "something" assert util.slugify("") == "" + assert util.slugify(None) == "" def test_repr_helper(): diff --git a/tests/util/test_location.py b/tests/util/test_location.py index 21531a59194..d25e6859727 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -1,5 +1,5 @@ """Test Home Assistant location util methods.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import aiohttp import pytest @@ -76,11 +76,15 @@ async def test_detect_location_info_whoami(aioclient_mock, session): """Test detect location info using whoami.home-assistant.io.""" aioclient_mock.get(location_util.WHOAMI_URL, text=load_fixture("whoami.json")) - info = await location_util.async_detect_location_info(session, _test_real=True) + with patch("homeassistant.util.location.HA_VERSION", "1.0"): + info = await location_util.async_detect_location_info(session, _test_real=True) + + assert str(aioclient_mock.mock_calls[-1][1]) == location_util.WHOAMI_URL assert info is not None assert info.ip == "1.2.3.4" assert info.country_code == "XX" + assert info.currency == "XXX" assert info.region_code == "00" assert info.city == "Gotham" assert info.zip_code == "12345" @@ -90,6 +94,17 @@ async def test_detect_location_info_whoami(aioclient_mock, session): assert info.use_metric +async def test_dev_url(aioclient_mock, session): + """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"): + info = await location_util.async_detect_location_info(session, _test_real=True) + + assert str(aioclient_mock.mock_calls[-1][1]) == location_util.WHOAMI_URL_DEV + + assert info.currency == "XXX" + + async def test_whoami_query_raises(raising_session): """Test whoami query when the request to API fails.""" info = await location_util._get_whoami(raising_session)